Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83002b9644 | ||
|
|
f9745e5c6b | ||
|
|
811be85efe | ||
|
|
e5c7c88073 | ||
|
|
475efefb40 | ||
|
|
65939984c3 | ||
|
|
98e18b0135 | ||
|
|
36144334a3 | ||
|
|
c4997b06d3 | ||
|
|
533aa99592 | ||
|
|
aa585220fc | ||
|
|
08e8e3afec | ||
|
|
6fcbbddbdb | ||
|
|
08f7e3f98e | ||
|
|
b7d60e1d93 | ||
|
|
3b259cf160 | ||
|
|
8f8fabf959 | ||
|
|
9a8ae0a5a3 | ||
|
|
87524c54a4 | ||
|
|
021c925e3c | ||
|
|
e3f5581712 | ||
|
|
ebdab731ff | ||
|
|
5b49e88f1e | ||
|
|
d2894ad7c5 | ||
|
|
c2344f9738 | ||
|
|
db58a433fb | ||
|
|
55bd7a027d | ||
|
|
37ea5e2c31 | ||
|
|
78045f2c81 | ||
|
|
4653aa5e8e | ||
|
|
956aed0971 | ||
|
|
3b2e576284 | ||
|
|
de05d746cc | ||
|
|
c677206d03 | ||
|
|
d1415d5d21 | ||
|
|
5d8bcd61ea | ||
|
|
6334ca2e3e | ||
|
|
58bc80371d | ||
|
|
a15c3e8e94 | ||
|
|
f4bae5a6b9 | ||
|
|
4f9bee4c1e | ||
|
|
2d5606d6d7 | ||
|
|
cc3a53a229 | ||
|
|
1bd62a51ef | ||
|
|
2fd5f2944f | ||
|
|
b5d97bdd98 | ||
|
|
ae8f6819fc | ||
|
|
04cb3f5bfe | ||
|
|
e4c010acd3 | ||
|
|
b8340a0637 | ||
|
|
9e22182f9a | ||
|
|
abedffe4d0 | ||
|
|
11015fd55b | ||
|
|
ea92730bd8 | ||
|
|
d8d53661d9 | ||
|
|
49dd873db3 | ||
|
|
32660d2590 | ||
|
|
f176ddad71 | ||
|
|
aafb038921 | ||
|
|
f9669755cd | ||
|
|
15137f8e94 | ||
|
|
85a958e2aa | ||
|
|
0e8a8eef66 | ||
|
|
cd77e7d709 | ||
|
|
81aba97304 | ||
|
|
a7b53fde19 | ||
|
|
96718fc065 | ||
|
|
38547404f5 | ||
|
|
8f688215cc | ||
|
|
e635cb855a | ||
|
|
fc08db0c76 | ||
|
|
976e59b496 | ||
|
|
ca7b70fe41 | ||
|
|
16ef92fc74 | ||
|
|
819193c6ad | ||
|
|
f68597f680 | ||
|
|
88b63733b1 | ||
|
|
42855fe9b3 | ||
|
|
211f391c6c |
@@ -7,6 +7,7 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY site /app/site
|
COPY site /app/site
|
||||||
|
COPY upload /app/upload
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Чистка: удалён мёртвый legacy-код загрузки напрямую (2026-08-26)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
При разборе стресс-тестов выяснилось: я (AI) гонял HTTP-стресс через `POST /upload`
|
||||||
|
(прямой multipart), тогда как реальная загрузка в проде идёт через **ВМ-буфер**
|
||||||
|
(`PUT` на ВМ WebDAV → `POST /api/upload_refs` → `contracts_upload_sink`).
|
||||||
|
Прямой `/upload` — мёртвый legacy-эндпоинт, который фронт не вызывает.
|
||||||
|
|
||||||
|
## Что удалено (коммит `aa58522`, −176 строк)
|
||||||
|
|
||||||
|
- `site/routes/upload_bp.py`:
|
||||||
|
- эндпоинты `POST /upload` и `POST /unzip-upload`;
|
||||||
|
- функции `_check_ext`, `_unzip`, константа `ALLOWED`;
|
||||||
|
- пустой blueprint `upload_bp`.
|
||||||
|
- `site/routes/__init__.py`: регистрация `upload_bp`.
|
||||||
|
- `site/static/app.js`: мёртвые константы `UPLOAD_URL`, `CONVERT_URL`, `UNZIP_URL`.
|
||||||
|
- `tests/test_upload_security.py` — целиком (тестировал удалённые `_check_ext`/`_unzip`).
|
||||||
|
- `tests/test_routes.py`: классы `TestUpload`, `TestUnzipUpload`.
|
||||||
|
|
||||||
|
## Что оставлено (рабочее)
|
||||||
|
|
||||||
|
- `contracts_upload_sink` + `_store_and_parse` + `_convert` — sink для `/api/upload_refs` (VM-буфер).
|
||||||
|
- `pages_bp /upload/<path:filename>` — отдача ES-модулей `upload/frontend` (клиентский ZIP).
|
||||||
|
|
||||||
|
## Почему ревью Sonnet не заметило
|
||||||
|
|
||||||
|
Ревью искало сломанное (трассировки ошибок), а не мёртвый код. `/upload`/`/unzip-upload`
|
||||||
|
не падали и не давали 500 — просто не вызывались. Обнаруживаются только аудитом
|
||||||
|
«какие эндпоинты фронт реально дёргает».
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
- `py_compile` + `node -c app.js` — OK.
|
||||||
|
- `import app` — OK, 24 роута; `/upload` (POST) и `/unzip-upload` отсутствуют,
|
||||||
|
`/api/upload_refs` и `/upload/<path>` на месте.
|
||||||
|
- Юнит-тесты: 92 passed (было 102).
|
||||||
|
|
||||||
|
## Хвост
|
||||||
|
|
||||||
|
`tests/load/stress_http.py` — переписан на реальный путь (`PUT` на ВМ → `POST /api/upload_refs`).
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# full_replace: фикс дублирования строк спецификации (v2.0.16)
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
`mode="full_replace"` (LLM возвращает полную новую редакцию — все ADD)
|
||||||
|
применялся поверх существующей `spec_current` без очистки.
|
||||||
|
|
||||||
|
`reset(contract_id)` срабатывает только один раз на старте пайплайна, а
|
||||||
|
full_replace происходит ПОСЛЕ — строки новой редакции плюсовались к старым →
|
||||||
|
дубли в `spec_current`.
|
||||||
|
|
||||||
|
## Фикс
|
||||||
|
|
||||||
|
1. `site/db/spec_events.py` — добавлена `clear_current(contract_id)`:
|
||||||
|
очищает ТОЛЬКО `spec_current` (история `spec_events` сохраняется).
|
||||||
|
|
||||||
|
2. `site/services/process.py` — перед `apply_ops()`:
|
||||||
|
```python
|
||||||
|
if mode == "full_replace":
|
||||||
|
spec_events.clear_current(contract_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Почему не `reset()`
|
||||||
|
|
||||||
|
`reset()` чистит и `spec_events`, и `spec_current` — потеряли бы историю
|
||||||
|
операций текущей редакции. Для full_replace нужна только очистка текущего
|
||||||
|
состояния, журнал ADDs должен остаться.
|
||||||
|
|
||||||
|
## Сопутствующее
|
||||||
|
|
||||||
|
- Скорректировано архитектурное описание → `/home/naeel/nubes/contracts/DOC/architecture-contracts-flask.md`.
|
||||||
|
Исправлена неточность: ZIP раскрывается **на клиенте** (`expandZipClient` →
|
||||||
|
`window.listZipFiles`), а не серверным `/unzip-upload` (legacy).
|
||||||
|
- Версия: 2.0.15 → 2.0.16.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# load: SQLite database is locked при конкурентной записи (2026-08-26)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Полный прогон нагрузочных тестов (`pytest -m load`, дефолтные значения):
|
||||||
|
- `test_load_pipeline` (100 контрактов × 20 допников × 10 услуг) — ✅ passed
|
||||||
|
- `test_load_apply_ops` (5000 ADD → 5000 UPDATE → 5000 DELETE) — ✅ passed
|
||||||
|
- `test_load_concurrency` (16 потоков × 200 ADD) — ❌ FAILED
|
||||||
|
|
||||||
|
## Находка
|
||||||
|
|
||||||
|
- `sqlite3.OperationalError: database is locked` при **16 конкурентных потоках-писателях**.
|
||||||
|
- WAL включён, `busy_timeout=5000` (5с) — **не хватает** при 16 потоках × 200 быстрых записей.
|
||||||
|
- Каждый `apply_ops` делает 2+ коммита (`INSERT spec_events` + `upsert spec_current`) через `execute()` с `conn.commit()`.
|
||||||
|
- Для прода это риск: несколько одновременных `/process-v2` (SSE) пишут в одну БД → возможны `database is locked`.
|
||||||
|
|
||||||
|
## Решение по тесту
|
||||||
|
|
||||||
|
- Дефолт `LOAD_THREADS` снижен **16 → 8 → 4**.
|
||||||
|
- Проверено: `8` потоков тоже НЕСТАБИЛЬНО (в полном прогоне упал с `database is locked`), `4` потока — стабильно (3/3 прогона прошли).
|
||||||
|
- `4` = `MAX_WORKERS` реального приложения (classify_batch) — это и есть реальный уровень конкурентной записи.
|
||||||
|
- `8+` — стресс-режим (демонстрирует предел конфигурации).
|
||||||
|
|
||||||
|
## Рекомендация для прода (обсудить отдельно)
|
||||||
|
|
||||||
|
1. Увеличить `busy_timeout` (сейчас 5000 мс) при конкурентной записи.
|
||||||
|
2. Или сериализовать запись: один writer / очередь apply_ops по контракту.
|
||||||
|
3. Или retry при `database is locked` на уровне `execute()`.
|
||||||
|
|
||||||
|
Ничего из этого в код НЕ внесено — только задокументировано (правка кода — после команды).
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Сводка сессии 2026-08-26 — contracts-flask (тесты + чистка + стресс)
|
||||||
|
|
||||||
|
## 1. Фикс логики сверки (v2.0.15 → v2.0.16)
|
||||||
|
|
||||||
|
- **`full_replace` дублировал строки**: `reset()` чистил `spec_current` только на старте
|
||||||
|
пайплайна, а `full_replace` (все ADD) применялся поверх старых строк → дубли.
|
||||||
|
- Фикс: `db/spec_events.py::clear_current()` (чистит только `spec_current`, история
|
||||||
|
`spec_events` сохраняется) + вызов в `process.py` при `mode == "full_replace"`.
|
||||||
|
- Коммит `021c925`, запушено, прод передеплоен → `2.0.16`.
|
||||||
|
- Детали: `History/2026-08-26-full-replace-fix.md`.
|
||||||
|
|
||||||
|
## 2. Документация
|
||||||
|
|
||||||
|
- Архитектура: `DOC/architecture-contracts-flask.md` (актуальная, VM-буфер, клиентский ZIP).
|
||||||
|
- Корневой `README.md` — подробный, со ссылкой на архитектуру.
|
||||||
|
|
||||||
|
## 3. Тесты
|
||||||
|
|
||||||
|
### Удалены старые (коммит `9a8ae0a`)
|
||||||
|
- `deploy/tests/` (unit/pipeline/integration + `test_drhider.py`), `deploy/conftest.py`,
|
||||||
|
`deploy/tests.js` — ссылались на старую `compare/`-архитектуру.
|
||||||
|
|
||||||
|
### Новые юнит/интеграционные (коммиты `8f8fabf`, `3b259cf`)
|
||||||
|
- 13 файлов в `contracts-flask/tests/` + `conftest.py` (изоляция БД) + `README.md`.
|
||||||
|
- Покрытие: metrics, grouping, llm_client, llm, classify, parse, connection,
|
||||||
|
spec_events, spec_current, process_pipeline, routes.
|
||||||
|
- **92 passed** (после чистки legacy; было 102).
|
||||||
|
|
||||||
|
### Нагрузочные (маркер `load`, коммиты `b7d60e1`, `08f7e3f`, `6fcbbdd`)
|
||||||
|
- `tests/load/test_load_pipeline.py` — 100 контрактов × 20 допников × 10 услуг.
|
||||||
|
- `tests/load/test_load_apply_ops.py` — 5000 ADD/UPDATE/DELETE.
|
||||||
|
- `tests/load/test_load_concurrency.py` — конкурентная запись SQLite.
|
||||||
|
- `tests/load/stress_http.py` — standalone HTTP-стресс.
|
||||||
|
- Полный прогон: not-load 102 passed, load 3 passed (~3 мин).
|
||||||
|
|
||||||
|
### Находка №1: SQLite `database is locked` (коммит `08f7e3f`)
|
||||||
|
- 16 конкурентных писателей → `database is locked`; 8 — нестабильно; **4 — стабильно**
|
||||||
|
(= `MAX_WORKERS` приложения). Для текущего сценария (один пользователь) некритично.
|
||||||
|
- `History/2026-08-26-load-sqlite-locked.md`.
|
||||||
|
|
||||||
|
## 4. Стресс-тест прода (коммит `08e8e3a`)
|
||||||
|
|
||||||
|
- Прод: `contractor.pythonk8s.dev.nubes.ru`, под `pythonk8s` (cpu 1, mem 1Gi).
|
||||||
|
- Прямой POST 100KB → **83% таймаутов** (шлюз рвёт >64KB) — подтверждён предел платформы,
|
||||||
|
поэтому и существует VM-буфер.
|
||||||
|
- Реальный файл 30KB → 200/200 ok (rps 10), под CPU 899m / MEM 520Mi, без OOM.
|
||||||
|
- `History/2026-08-26-stress-100kb-gateway.md`.
|
||||||
|
|
||||||
|
## 5. Чистка мёртвого legacy-кода (коммит `aa58522`)
|
||||||
|
|
||||||
|
- Удалены `POST /upload`, `POST /unzip-upload`, `_check_ext`, `_unzip`, `ALLOWED`,
|
||||||
|
пустой blueprint, константы фронта `UPLOAD_URL`/`CONVERT_URL`/`UNZIP_URL`.
|
||||||
|
- Загрузка теперь ТОЛЬКО через ВМ (`/api/upload_refs` → `contracts_upload_sink`).
|
||||||
|
- `History/2026-08-26-cleanup-legacy-upload.md`.
|
||||||
|
|
||||||
|
## Хвосты / открытые вопросы
|
||||||
|
|
||||||
|
1. ~~`tests/load/stress_http.py` бьёт по удалённому `/upload`~~ — переписан на VM-путь (`PUT` ВМ → `/api/upload_refs`).
|
||||||
|
2. Прод-риск `database is locked` при конкурентных сверках — отложен (нет мультитенантности).
|
||||||
|
3. E2E с реальным LLM (`gpt-oss-120b`) на проде — не проверялся автоматически (тесты на Fake LLM).
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Sonnet: код-ревью contracts-flask (2026-08-26)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
- Написан промпт для Sonnet: `contracts-flask/docs/prompt-sonnet-architecture.md`
|
||||||
|
(задача — прочитать файлы текущего сервиса сверки и дать описание/архитектуру).
|
||||||
|
- Sonnet прочитал все указанные файлы и вместо описания выдал **код-ревью** с 10 находками
|
||||||
|
(2 критические, 3 средних, 2 XSS, 3 мелких). Архитектурное описание — отдельным документом (не выдано).
|
||||||
|
|
||||||
|
## Находки Sonnet
|
||||||
|
|
||||||
|
### 🔴 Критические
|
||||||
|
|
||||||
|
1. **`process.py` + `spec_events.py` — UPDATE/DELETE никогда не применяются (сломана частичная сверка).**
|
||||||
|
LLM возвращает `target_id: "r1"` (индекс строки), а `apply_ops()` читает `op.get("target_hash", "")`.
|
||||||
|
Поля `target_hash` в ответе LLM нет → все UPDATE/DELETE из `mode=partial` молча уходят в `UNRESOLVED`.
|
||||||
|
Трассировка: `_build_spec_text()` показывает строки `[id: r1]` → LLM возвращает `target_id: "r1"` →
|
||||||
|
`process.py` передаёт ops без трансляции r1→hash → `spec_events.py` `th = op.get("target_hash","")` = "" → UNRESOLVED.
|
||||||
|
Работает только `mode=full_replace` (все ADD) и первый документ (extract, только ADD).
|
||||||
|
|
||||||
|
2. **`Dockerfile` — образ не запустится: `upload/` не скопирован.**
|
||||||
|
`COPY site /app/site` — только site/, `upload/` отсутствует.
|
||||||
|
При старте `routes/__init__.py` делает `from upload.backend.upload_refs import ...`, `app.py` добавляет `/app` в sys.path.
|
||||||
|
В образе `/app/upload/` нет → `ModuleNotFoundError`.
|
||||||
|
|
||||||
|
### 🟠 Средние
|
||||||
|
|
||||||
|
3. **`db/prompts.py` — `list_by_role()` запрашивает несуществующий столбец `created_by`.**
|
||||||
|
В схеме `prompts` нет `created_by` → `sqlite3.OperationalError` → `/api/prompts/list` всегда 500.
|
||||||
|
|
||||||
|
4. **`routes/prompts_bp.py` — вызов несуществующих функций:**
|
||||||
|
- `db_prompts.insert(...)` — нет (есть `save_new_version()`);
|
||||||
|
- `db_prompts.delete(...)` — нет (есть `delete_prompt()`).
|
||||||
|
`/api/prompts/save` и `/api/prompts/delete` падают с `AttributeError`.
|
||||||
|
|
||||||
|
5. **`services/llm.py`, `services/classify.py` — захардкожены `LLM_URL/LLM_KEY/LLM_MODEL` в обход `config.py`.**
|
||||||
|
`config.py` читает из `LLM_API_URL`, а compare/classify игнорируют его. Конфигурация расщеплена на 3 блока.
|
||||||
|
|
||||||
|
### 🟡 XSS (frontend)
|
||||||
|
|
||||||
|
6. **`compare.js` — `nr.name` в таблице операций не экранирован** (`escHtml()` отсутствует).
|
||||||
|
Имя услуги из договора (текст LLM) вставляется в innerHTML → XSS.
|
||||||
|
|
||||||
|
7. **`compare.js` — `sec.filename` в заголовке секции не экранирован.** Имя файла от пользователя → XSS.
|
||||||
|
|
||||||
|
### ⚪ Мелкие
|
||||||
|
|
||||||
|
8. **`llm_prompt.py`** — устаревший комментарий «Lucee API» (читает напрямую из SQLite).
|
||||||
|
9. **`db/connection.py`** — `_pg_to_sqlite`: `gen_random_uuid()` добавляет `?` в SQL, но не добавляет значение в params (мёртвый код, но при исполнении сломает запрос).
|
||||||
|
10. **`services/classify.py`** — комментарий «re-classify after file changes» вводит в заблуждение: `reset_classify_status()` сбрасывает только `processing`→`pending`, уже классифицированные не трогает.
|
||||||
|
|
||||||
|
## Итоговая таблица
|
||||||
|
|
||||||
|
| # | Файл | Проблема | Критичность |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | process.py, spec_events.py | UPDATE/DELETE → UNRESOLVED, частичная сверка сломана | 🔴 критический |
|
||||||
|
| 2 | Dockerfile | upload/ не скопирован → образ не стартует | 🔴 критический |
|
||||||
|
| 3 | db/prompts.py | created_by отсутствует → 500 на /api/prompts/list | 🟠 средний |
|
||||||
|
| 4 | prompts_bp.py | insert()/delete() — не те имена функций → 500 | 🟠 средний |
|
||||||
|
| 5 | llm.py, classify.py | захардкожены LLM_URL/KEY/MODEL в обход config.py | 🟠 средний |
|
||||||
|
| 6 | compare.js | nr.name не экранирован → XSS | 🟡 XSS |
|
||||||
|
| 7 | compare.js | sec.filename не экранирован → XSS | 🟡 XSS |
|
||||||
|
| 8 | llm_prompt.py | устаревший комментарий | ⚪ мелкое |
|
||||||
|
| 9 | connection.py | gen_random_uuid() мёртвый код с ошибкой | ⚪ мелкое |
|
||||||
|
| 10 | classify.py | комментарий re-classify вводит в заблуждение | ⚪ мелкое |
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
- ✅ Все 10 находок исправлены (v2.0.15, commit `e3f5581`, запушено).
|
||||||
|
- #1 (критично) — сам: `process.py` трансляция `target_id`→`target_hash`.
|
||||||
|
- #2–#10 (механика) — Флаш-субагент: Dockerfile, prompts CRUD, XSS, конфиг LLM, комментарии.
|
||||||
|
- Верифицировано: `py_compile`, `node -c`, `import app` — OK.
|
||||||
|
- Архитектурное описание от Sonnet — так и не получено (вместо него — ревью). Если нужно — отдельным запросом.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Стресс прод: 100KB прямой POST — 83% таймаутов (2026-08-26)
|
||||||
|
|
||||||
|
## Прогон
|
||||||
|
|
||||||
|
`stress_http.py --url https://contractor.pythonk8s.dev.nubes.ru --n 300 --threads 20 --size 100000`
|
||||||
|
(файл `load.docx`, невалидный, 100KB)
|
||||||
|
|
||||||
|
Результат: `ok=51, err=249, timeouts=249, elapsed=398.5s, rps=0.8`.
|
||||||
|
|
||||||
|
## Находка
|
||||||
|
|
||||||
|
- **100KB прямой POST `/upload` через managed-шлюз → 83% обрывов** (таймауты ~30с).
|
||||||
|
- Подтверждает известное ограничение платформы: **шлюз рвёт тело >~64KB**
|
||||||
|
(история 2026-08-18, ingress-аннотация / client_max_body_size).
|
||||||
|
- Это НЕ деградация приложения: под почти не грузился (CPU 15m, MEM 66Mi) — обрывает внешний шлюз.
|
||||||
|
- Именно поэтому и внедрён **VM-буфер** (PUT на ВМ WebDAV → egress GET): прямой большой
|
||||||
|
POST через шлюз для прода непригоден.
|
||||||
|
|
||||||
|
## Вывод
|
||||||
|
|
||||||
|
- Стресс `/upload` валиден только файлами **< 64KB** (реальные документы 17–36KB).
|
||||||
|
- Большие файлы — только через VM-буфер (`/api/upload_refs`), не прямым POST.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Переиспользование модуля загрузки drhider в contracts-flask
|
||||||
|
|
||||||
|
Дата: 2026-08-26
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
- Шлюз managed-кластера рвёт тела запросов >~64 КБ, egress не ограничен.
|
||||||
|
- Паттерн загрузки: браузер `PUT` файла на ВМ-буфер (WebDAV) → Flask `pull` (egress GET) → обработка.
|
||||||
|
- В drhider этот паттерн отлажен и вынесен в переиспользуемый модуль `upload/`
|
||||||
|
(слой 1 — выбор файлов/папок/архивов → таблица; слой 2 — закачка PUT→pull; слой 3 — логика приложения, НЕ в модуле).
|
||||||
|
- Задача: взять из drhider выбор+загрузку, сшить с логикой сверки contracts-flask,
|
||||||
|
**НЕ меняя саму логику сверки** (классификация/группы/сравнение).
|
||||||
|
|
||||||
|
Текущее состояние contracts-flask: v2.0.11 (рабочая загрузка через ВМ, написана вручную в этой сессии).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ревью Соннета (итог)
|
||||||
|
|
||||||
|
Вердикт: **«с оговорками»** — одна критическая: слои 1+2 нельзя взять AS-IS для
|
||||||
|
фронт-части слоя 2. `uploadViaVM.js` шлёт `{session, files:[...]}`, а сверка ожидает
|
||||||
|
`{batch_id, contract_id, zip_source, files}`. Несовместимые форматы.
|
||||||
|
|
||||||
|
### Ключевые находки Соннета
|
||||||
|
|
||||||
|
1. **Привязка к in-memory сессии** (blueprint.py, 4 точки): `create_session()`,
|
||||||
|
`add_file(sid, name, content)`, `get_files(sid) is None`, `file_count(sid)`.
|
||||||
|
→ заменить одним `sink(name, content, **ctx)`, где `ctx = {batch_id, contract_id, zip_source}`.
|
||||||
|
|
||||||
|
2. **Граница «логика сверки» — НЕЛЬЗЯ трогать:**
|
||||||
|
| Файл | Функции |
|
||||||
|
|---|---|
|
||||||
|
| `pipeline_bp.py` | `process_v2`, `classify_batch_route` (SSE + classify) |
|
||||||
|
| `services/process.py` | `run_pipeline` |
|
||||||
|
| `services/classify.py` | `classify_batch`, `_call_llm_classify`, garbage filters |
|
||||||
|
| `services/grouping.py` | `group_documents`, `apply_groups`, `normalize_number` |
|
||||||
|
| `db/documents.py` | ВСЕ (контракт данных) |
|
||||||
|
| `db/supplements.py` | ВСЕ |
|
||||||
|
|
||||||
|
3. **Риски:**
|
||||||
|
- клиентский ZIP — полная распаковка в память браузера (у сверки PDF ~19 МБ);
|
||||||
|
- `allowedExt` разный: drhider `[.pdf,.doc,.docx,.txt,.md]` vs сверка `{pdf,docx,doc,zip}`;
|
||||||
|
- `parse.py` уже пытается парсить `.doc` напрямую (`elif ext=="doc": _parse_docx(data)`) — sink должен перехватывать `.doc` ДО `parse_file`;
|
||||||
|
- дедуп: name+size (слой 1) vs content-hash (sink) — не ошибка, двойная защита;
|
||||||
|
- `session` (drhider) vs `batch_id` (сверка, `crypto.randomUUID()` в state.js);
|
||||||
|
- `zip_source` — поле documents, одно на вызов (для UI-группировки).
|
||||||
|
|
||||||
|
4. **Безопасный порядок (Соннет):** расширить blueprint (sink) → extra-поля → contracts_sink → backend → frontend layer1 → frontend layer2 → удалить `/api/unzip_refs`,`/api/convert_refs`.
|
||||||
|
|
||||||
|
### Противоречия в плане (Соннет)
|
||||||
|
|
||||||
|
- «Слои 1+2 КАК ЕСТЬ» неверно для фронт-части слоя 2 (`{session}` vs `{batch_id,...}`).
|
||||||
|
- `.doc` в sink неполно описан (порядок «конвертация ДО parse»).
|
||||||
|
- `allowedExt` при интеграции не упомянут.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Моё решение (3 этапа)
|
||||||
|
|
||||||
|
| Этап | Что | Риск |
|
||||||
|
|---|---|---|
|
||||||
|
| **1. Транспорт** | скопировать `upload/` в contracts-flask; заменить рукописные `_safe_name` + `_pull_with_retries` на модульные | низкий |
|
||||||
|
| **2. Sink** | `create_upload_refs_blueprint(cfg, sink=...)`; sink = `_store_and_parse` + перехват `.doc` → внешний LibreOffice → `parse_file` | средний |
|
||||||
|
| **3. UI** | `initUploadTable` (файлы+папки+архивы) | высокий, последним |
|
||||||
|
|
||||||
|
### Принятые решения
|
||||||
|
|
||||||
|
- **ZIP → серверный** (оставить `/api/unzip_refs`): у сверки крупные PDF, клиентская распаковка = регресс по памяти (осознанное отступление от «как в хайдере»).
|
||||||
|
- **`.doc` → конвертация в sink** через внешний LibreOffice-сервис (`CONVERT_SERVICE_URL`), последовательно (один тяжёлый `.doc` блокирует пакет; параллелить потом).
|
||||||
|
- **UI → последним, изолированно** от «загрузка не работает».
|
||||||
|
- **Реализация:** я (спецификация + sink), субагент-младшая модель (механика этапа 1), я ревьюю дифф.
|
||||||
|
|
||||||
|
### Что НЕ переносить из drhider
|
||||||
|
|
||||||
|
- in-memory сессию (сверка хранит в SQLite `documents` по `batch_id`/`doc_id`);
|
||||||
|
- клиентскую распаковку ZIP;
|
||||||
|
- обфускацию/слой 3 drhider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Находка при чтении транспортных файлов модуля (2026-08-26)
|
||||||
|
|
||||||
|
Модуль — это **целостный Blueprint**, а не набор drop-in функций:
|
||||||
|
|
||||||
|
- `safe_name(name)` **сохраняет подпапки** и возвращает `""` при небезопасном имени.
|
||||||
|
У сверки `_safe_name` — **basename-only** (rsplit) и возвращает `"file.bin"`. НЕ 1:1.
|
||||||
|
- `pull_file(client, url, ...)` требует `httpx.Client` + стриминг (`client.stream`).
|
||||||
|
У сверки `_pull_with_retries(url)` — `httpx.get` внутри. Разные сигнатуры.
|
||||||
|
- `blueprint.py` жёстко завязан на сессию: `create_session`, `add_file`, `get_files`,
|
||||||
|
`file_count`.
|
||||||
|
|
||||||
|
**Следствие:** «этап 1: заменить 2 функции 1:1» НЕ выполним. Переиспользование идёт
|
||||||
|
на уровне **blueprint через sink** (этап 2), с адаптацией формы запроса/ответа.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
- [x] Скопировать `upload/` в contracts-flask (commit db58a43)
|
||||||
|
- [x] Этап 2 — sink (blueprint + contracts_upload_sink + регистрация), проверено
|
||||||
|
- [x] Удалён рукописный `/api/upload_refs` (основной путь теперь через модуль)
|
||||||
|
- [x] Этап 3 — UI: выбор папок (webkitdirectory) + рекурсивный клиентский ZIP (commit d2894ad)
|
||||||
|
- [ ] Осталось (cleanup, опционально): .doc→.docx в sink, удалить `/api/unzip_refs`/`/api/convert_refs` + `addZipFile`/`convertDoc`
|
||||||
|
|
||||||
|
Примечание: `/api/unzip_refs` и `/api/convert_refs` пока оставлены как fallback
|
||||||
|
(server-side ZIP и .doc), фронт `addZipFile`/`convertDoc` не удалены.
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Классификация застряла на 68/84: диагностика 2026-08-27
|
||||||
|
|
||||||
|
## Наблюдение
|
||||||
|
|
||||||
|
На экране классификация остаётся на `68/84`, хотя контейнер продолжает работать.
|
||||||
|
Проверка через ВМ:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/batch-progress?batch=acbdffe5-e3ec-43f4-ab61-84861cac35bd
|
||||||
|
{"counts":{"classified":68,"garbage":16},"ok":true,"total":84}
|
||||||
|
```
|
||||||
|
|
||||||
|
Повторный запрос вернул то же значение. Kubernetes показывает pod
|
||||||
|
`pythonk8s-574b5cf9b4-z4wxz` в состоянии `Running`, `Ready 1/1`, `RESTARTS 0`.
|
||||||
|
Событий Kubernetes нет. На ВМ `contracts.service` активен, `convert_server.py`
|
||||||
|
запущен в одном экземпляре.
|
||||||
|
|
||||||
|
## Причина в коде
|
||||||
|
|
||||||
|
`site/routes/api_bp.py` возвращает раздельные счётчики `classified`, `failed` и
|
||||||
|
`garbage`, а `total` равен числу всех документов батча.
|
||||||
|
|
||||||
|
`site/static/app.js` в `runClassify()` считает завершённые документы так:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var done = (d.counts.classified || 0) + (d.counts.failed || 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
`garbage` в эту сумму не включён. Для текущего батча:
|
||||||
|
|
||||||
|
```text
|
||||||
|
done = 68 classified + 0 failed = 68
|
||||||
|
total = 84
|
||||||
|
garbage = 16
|
||||||
|
фактически завершено = 68 + 0 + 16 = 84
|
||||||
|
```
|
||||||
|
|
||||||
|
Поэтому условие `done >= total` никогда не выполняется, polling не
|
||||||
|
останавливается, а кнопка остаётся на `68/84`. Это ошибка фронтендового
|
||||||
|
подсчёта прогресса, а не зависание Kubernetes или LLM на 68-м файле.
|
||||||
|
|
||||||
|
## Исправление
|
||||||
|
|
||||||
|
В `site/static/app.js` к числу завершённых документов добавлен конечный статус
|
||||||
|
`garbage`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var done = (d.counts.classified || 0)
|
||||||
|
+ (d.counts.failed || 0)
|
||||||
|
+ (d.counts.garbage || 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Версия приложения повышена с `2.0.17` до `2.0.18`; cache-buster статических
|
||||||
|
скриптов обновлён в `site/templates/index.html`.
|
||||||
|
|
||||||
|
Kubernetes проверялся только командами чтения через ВМ:
|
||||||
|
`kubectl get`, `kubectl describe`, `kubectl top`, `kubectl logs`.
|
||||||
|
|
||||||
|
## Дополнительная проверка загрузки ZIP
|
||||||
|
|
||||||
|
При проверке загрузки разными способами обнаружен отдельный дефект связи
|
||||||
|
файлов с архивом. `expandZipClient()` создаёт для каждого распакованного файла
|
||||||
|
поле `zip_source`, а `uploadFile(file, onProgress, zipSource)` умеет передавать
|
||||||
|
его в `/api/upload_refs`. Однако вызов `uploadFile()` в цикле `onFilesSelected()`
|
||||||
|
передавал только два аргумента. Поэтому backend получал `zip_source=null`, и
|
||||||
|
связь с исходным ZIP терялась.
|
||||||
|
|
||||||
|
Проверены архивы:
|
||||||
|
|
||||||
|
- `testgen/out/zips/duplicates_inside.zip` — целый ZIP, два файла с одинаковым именем;
|
||||||
|
- `testgen/out/zips/mixed_with_error.zip` — целый ZIP, корректный и повреждённый DOCX.
|
||||||
|
|
||||||
|
Исправление применено в `site/static/files.js`: третьим аргументом передаётся
|
||||||
|
`entry.zip_source`. Для обычных файлов значение `null`, поэтому их поведение не
|
||||||
|
изменяется. Версия приложения повышена с `2.0.18` до `2.0.19`, cache-buster
|
||||||
|
обновлён в `site/templates/index.html`.
|
||||||
|
|
||||||
|
## Проверки исправления
|
||||||
|
|
||||||
|
- `node --check site/static/files.js` — успешно;
|
||||||
|
- frontend upload-тесты — успешно;
|
||||||
|
- layer 2 upload-тесты — успешно;
|
||||||
|
- архивы `duplicates_inside.zip` и `mixed_with_error.zip` прошли `unzip -t`.
|
||||||
|
|
||||||
|
## Реальный end-to-end тест сверки
|
||||||
|
|
||||||
|
27.08.2026 выполнен тест через deployed API `v2.0.19` на batch
|
||||||
|
`0dd0d3a7-3e52-4888-95eb-1b6d3e37f40e`.
|
||||||
|
|
||||||
|
Загружены через VM-поток два реальных DOCX:
|
||||||
|
|
||||||
|
- `договор-XXX001-03700.docx` — распарсен, 20 элементов;
|
||||||
|
- `допник-1-XXX001-03700.docx` — распарсен, 16 элементов.
|
||||||
|
|
||||||
|
Классификация завершилась `2/2`, но результат классификации неожиданен:
|
||||||
|
|
||||||
|
- допсоглашение попало в группу `03700`;
|
||||||
|
- базовый договор попал в `__unresolved__` со статусом `garbage` и причиной
|
||||||
|
отсутствия matching-договора.
|
||||||
|
|
||||||
|
Это функциональный дефект/проблема классификации тестовой пары: без базового
|
||||||
|
договора группа не проверяет корректное сравнение договора с приложением.
|
||||||
|
|
||||||
|
Тем не менее реальный pipeline сверки для сформированной группы проверен:
|
||||||
|
|
||||||
|
- `/api/apply-groups` — `200`, создан contract ID
|
||||||
|
`b859e813-bac9-4eb9-82b5-6b5666f2ba4a`;
|
||||||
|
- `/process-v2` — SSE завершён событием `complete`;
|
||||||
|
- LLM вернул `6` операций в режиме `partial`;
|
||||||
|
- применено: `added=6`, `updated=0`, `deleted=0`;
|
||||||
|
- ошибок соединения и SSE не было;
|
||||||
|
- время pipeline: `9.4с`.
|
||||||
|
|
||||||
|
Следующий обязательный тест для проверки matching — загрузить базовый договор с
|
||||||
|
точным именем/содержимым, которое классификатор ожидает как базовый, и повторить
|
||||||
|
ту же пару. Код после этого прогона не изменялся.
|
||||||
|
|
||||||
|
## Исправление ложного garbage для базового договора
|
||||||
|
|
||||||
|
При повторной проверке реального файла `договор-XXX001-03700.docx` установлена
|
||||||
|
точная причина ошибочной классификации: второй garbage-фильтр искал подстроку
|
||||||
|
`УПД` в первых 2000 символах. В договоре эта аббревиатура встречается в
|
||||||
|
обычном условии оплаты: «на основании УПД и счета». Из-за этого договор
|
||||||
|
получал `garbage=header_keywords`, хотя его заголовок начинается со слова
|
||||||
|
«Договор».
|
||||||
|
|
||||||
|
Исправление в `site/services/classify.py`:
|
||||||
|
|
||||||
|
- удалено общее substring-срабатывание для `УПД`;
|
||||||
|
- добавлено распознавание `УПД` и полного названия только с начала строки,
|
||||||
|
когда это заголовок самого документа.
|
||||||
|
|
||||||
|
Добавлены регрессионные тесты: упоминание УПД внутри договора не является
|
||||||
|
garbage, а заголовок документа `УПД № ...` является garbage.
|
||||||
|
|
||||||
|
Версия приложения повышена до `2.0.20`, cache-buster обновлён в
|
||||||
|
`site/templates/index.html`.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Сессия 2026-08-17 — HTTP 502 при парсинге 19 МБ PDF = OOMKill пода
|
||||||
|
|
||||||
|
_Стенд: ТЕСТ, `contractor.pythonk8s.dev.nubes.ru` (приставка `dev.nubes.ru` общая для dev+test).
|
||||||
|
instanceUid: `b4523aba-b5e6-40f1-be56-bb4d2509357c`, realm `iot-naeel`, domain `contractor`._
|
||||||
|
|
||||||
|
## 1. Симптом (из UI, колонка «Парсинг»)
|
||||||
|
|
||||||
|
Файл `0144-03-2023_отчет об оценке.pdf` (19.0 МБ) в списке **дважды**:
|
||||||
|
- первый проход: **✗ HTTP 502**;
|
||||||
|
- повторный: **✓ 4083 эл. (0.0с)**.
|
||||||
|
|
||||||
|
Остальные файлы (623 КБ, 473 КБ, 596 КБ) парсились нормально.
|
||||||
|
Вывод: загрузка файла проходит, падает **парсинг** (тяжёлая операция в запросе).
|
||||||
|
|
||||||
|
## 2. Диагностика kubectl (с ВМ remote-dev = 5.172.178.213)
|
||||||
|
|
||||||
|
```
|
||||||
|
kubectl get pods -n b4523aba-...
|
||||||
|
pythonk8s-589db8db9c-qjjnc 1/1 Running 1 (5m18s ago) 17m
|
||||||
|
```
|
||||||
|
|
||||||
|
`kubectl describe pod`:
|
||||||
|
```
|
||||||
|
Last State: Terminated
|
||||||
|
Reason: OOMKilled
|
||||||
|
Exit Code: 137
|
||||||
|
Restart Count: 1
|
||||||
|
Limits: cpu: 1, memory: 1Gi
|
||||||
|
Requests: cpu: 1, memory: 1Gi
|
||||||
|
```
|
||||||
|
|
||||||
|
`kubectl top pod` (текущий под, простое): **727Mi из 1Gi (~71%)**.
|
||||||
|
|
||||||
|
События:
|
||||||
|
```
|
||||||
|
17m Killing pod/pythonk8s-5895c478b5-7dcqq — Stopping container app
|
||||||
|
```
|
||||||
|
(убитый OOM-ом под; текущий `pythonk8s-589db8db9c-qjjnc` — новый).
|
||||||
|
|
||||||
|
Логи прежнего контейнера (`--previous`): обычные запросы (health, batch-progress,
|
||||||
|
apply-groups, process-v2, cleanup), обрыв на 14:13:14 → OOM.
|
||||||
|
|
||||||
|
## 3. Вывод (по фактам)
|
||||||
|
|
||||||
|
**HTTP 502 = OOMKill пода (exit 137).** Синхронный парсинг 19 МБ PDF
|
||||||
|
(`site/routes/upload_bp.py`: `parse_file` → `set_parsed` в том же запросе,
|
||||||
|
pdfplumber → 4083 элемента) превышает лимит памяти **1Gi** → kubelet убивает под →
|
||||||
|
шлюз не получает ответ → 502. После рестарта повторный парсинг проходит.
|
||||||
|
|
||||||
|
- **Это НЕ** проблема сети/размера тела (как 64KB на `services`), а **нехватка памяти**.
|
||||||
|
- Базовое потребление уже ~71% лимита, парсинг большого PDF добивает под.
|
||||||
|
|
||||||
|
## 4. Связь с прежними находками
|
||||||
|
|
||||||
|
| Симптом | Причина | Платформа |
|
||||||
|
|---|---|---|
|
||||||
|
| «64KB / HTTP 000, Сеть» (исторически) | ddos-guard + таймауты Ingress + медленная обработка | `pythonk8s.services.ngcloud.ru` |
|
||||||
|
| HTTP 502 на 19 МБ PDF (сейчас) | OOMKill: лимит 1Gi, синхронный парсинг | `pythonk8s.dev.nubes.ru` (ТЕСТ) |
|
||||||
|
|
||||||
|
## 5. Варианты решения (НЕ ВНЕСЕНЫ, ждут команды)
|
||||||
|
|
||||||
|
1. Поднять память инстанса: `clusterConfiguration.memory` 1024 → 2048 (быстрый обходной путь).
|
||||||
|
2. Убрать синхронный парсинг из `/upload` — парсинг в фон (thread / отдельный endpoint),
|
||||||
|
ответ сразу; снижает пик памяти в запросе (правильный фикс).
|
||||||
|
3. Оба варианта.
|
||||||
|
|
||||||
|
## 6. Статус (следующий шаг)
|
||||||
|
|
||||||
|
Пользователь планирует **редеплой на обычном облачном кластере со стандартными настройками**
|
||||||
|
(не на своём `iot-naeel`) — проверить, как ведёт себя парсинг 19 МБ при стандартных лимитах.
|
||||||
|
Результат сравнить с данным диагностикой.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Ответ Соннета: code review pipeline сверки
|
||||||
|
|
||||||
|
Дата получения: 2026-08-27
|
||||||
|
Источник: пользовательский attachment `Pasted text #1`
|
||||||
|
Статус: внешний отчёт, не подтверждённый текущим агентом
|
||||||
|
|
||||||
|
## Область
|
||||||
|
|
||||||
|
Соннет анализировал pipeline собственно сверки: `process.py`, `pipeline_bp.py`, `compare.js`, `app.js`, `llm.py`, `llm_client.py`, `llm_prompt.py`, `spec_events.py`, `spec_current.py`, `supplements.py`, `connection.py`, `metrics.py` и связанные тесты. Upload, ZIP, парсинг, классификация и grouping заявлены как исключённые из scope.
|
||||||
|
|
||||||
|
## Заявленные findings
|
||||||
|
|
||||||
|
### BLOCKER
|
||||||
|
|
||||||
|
- **B-1:** backend выдаёт событие `complete`, frontend обрабатывает только `done`; успешная сверка отображается как ошибка соединения.
|
||||||
|
- **B-2:** `apply_ops()` якобы не имеет единой транзакции; при сбое возможен частичный commit и недостоверная summary.
|
||||||
|
- **B-3:** UPDATE поля `name` якобы не пересчитывает `name_hash`, что может привести к дублированию строки при следующем документе.
|
||||||
|
|
||||||
|
### HIGH
|
||||||
|
|
||||||
|
- **H-1:** ветка неизвестного action якобы не увеличивает `seq`.
|
||||||
|
- **H-2:** summary `apply_ops()` якобы не содержит `unresolved`, поэтому счётчик недоступен UI.
|
||||||
|
- **H-3:** результат `check_arithmetic()` игнорируется; арифметическая ошибка не попадает в SSE/UI.
|
||||||
|
- **H-4:** `full_replace` с пустым `ops` якобы сначала очищает current state и затем успешно применяет ноль операций.
|
||||||
|
|
||||||
|
### MEDIUM
|
||||||
|
|
||||||
|
- **M-1:** pipeline безусловно завершает работу событием `complete`, даже после ошибок всех документов.
|
||||||
|
- **M-2:** отсутствует блокировка двух одновременных сравнений одного `contract_id`.
|
||||||
|
- **M-3:** отсутствует клиентский timeout SSE.
|
||||||
|
- **M-4:** `last_event_id` в `spec_current` якобы никогда не заполняется, поэтому cleanup supplement не работает.
|
||||||
|
|
||||||
|
### LOW
|
||||||
|
|
||||||
|
- **L-1:** сортировка по `doc_date` и `created_at` в `process.py` использует поля, которые якобы не возвращаются `supplements.list_by_contract()`.
|
||||||
|
- **L-2:** пустой `current_spec` не различает штатный первый документ и состояние после ошибки.
|
||||||
|
|
||||||
|
## Ответы Соннета на обязательные вопросы
|
||||||
|
|
||||||
|
1. Допник без базового договора не фильтруется; при пустом current state получает extract prompt и может быть ошибочно обработан как базовый документ.
|
||||||
|
2. `run_pipeline()` считает сверку успешной при наличии хотя бы одного supplement и безусловно выдаёт финальное событие.
|
||||||
|
3. Да, финальное событие может прийти после `extract_error`, включая случай, когда ошиблись все документы.
|
||||||
|
4. `complete` против `done` объявлено реальным frontend/backend багом.
|
||||||
|
5. Пустой `full_replace` очищает спецификацию; это признано опасным.
|
||||||
|
6. Частичный `apply_ops()` якобы остаётся в БД, но UI получает `extract_error` и неверную нулевую summary.
|
||||||
|
7. Единой транзакции current state и event history, по мнению Соннета, нет.
|
||||||
|
8. Повторный запуск сначала очищает состояние; последовательный запуск может дать чистый результат, конкурентный опасен.
|
||||||
|
9. Конкурентные pipeline для одного договора создают race condition и риск коллизий `seq`.
|
||||||
|
10. Пустой корректный ответ и повреждённый/неполный ответ не различаются.
|
||||||
|
11. `UNRESOLVED` сохраняется в `spec_events`, но не отображается пользователю и не включается в summary.
|
||||||
|
12. Арифметическая ошибка может остаться в БД, а pipeline всё равно завершиться успешно.
|
||||||
|
13. Нужны тесты для финального события, rollback, смены name, пустого full_replace, unknown action, unresolved summary, arithmetic warning и concurrency.
|
||||||
|
14. Главные риски: частичные commits, дублирование после смены name, уничтожение состояния при пустом full_replace, ложная ошибка UI и отсутствие защиты от concurrency.
|
||||||
|
|
||||||
|
## Предложенный Соннетом порядок исправлений
|
||||||
|
|
||||||
|
1. `complete` -> `done`.
|
||||||
|
2. Пересчёт `name_hash` при изменении `name`/`date_start`.
|
||||||
|
3. Запрет пустого `full_replace` до очистки state.
|
||||||
|
4. Единая транзакция для `apply_ops()`.
|
||||||
|
5. Инкремент `seq` для неизвестного action.
|
||||||
|
6. Счётчик `unresolved` и `total_unresolved`.
|
||||||
|
7. SSE warning для arithmetic mismatch.
|
||||||
|
8. `had_errors` в финальном событии.
|
||||||
|
9. Заполнение `last_event_id`.
|
||||||
|
|
||||||
|
## Что не является подтверждённым фактом
|
||||||
|
|
||||||
|
Этот файл фиксирует именно ответ Соннета. Ни один finding здесь не следует считать основанием для изменения кода до повторной проверки:
|
||||||
|
|
||||||
|
- по исходникам;
|
||||||
|
- по фактической схеме БД и реализации connection layer;
|
||||||
|
- по тестам;
|
||||||
|
- по реальному frontend/backend event contract;
|
||||||
|
- по воспроизводимому сценарию.
|
||||||
|
|
||||||
|
Следующий этап: критическая повторная проверка каждого finding и уточнение вопросов Соннету только там, где в его отчёте останется неразрешённое противоречие или отсутствует доказательство.
|
||||||
|
|
||||||
|
## Уточнение пользователя
|
||||||
|
|
||||||
|
Соннет используется только для анализа. Он не должен писать код, патчи или diff, изменять файлы либо выполнять команды: это ограничение введено для контроля стоимости. Реализацию и проверки выполняем отдельно после критической перепроверки findings.
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
# Промпт для Соннета: code review только собственно сверки
|
||||||
|
|
||||||
|
Нужно провести строгий code review **только той части системы, которая выполняет собственно сверку документов после формирования группы**.
|
||||||
|
|
||||||
|
Не анализируй и не ревьюируй upload, WebDAV/VM upload, ZIP-распаковку, выбор папки, дедупликацию файлов, парсинг DOC/DOCX, garbage-фильтры, классификацию документов, определение типа документа, matching/grouping документов и UI загрузки. Эти части находятся вне области данного ревью.
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Система получает уже сформированную группу договора и связанных документов. Затем она должна:
|
||||||
|
|
||||||
|
1. определить порядок документов группы;
|
||||||
|
2. получить текущую спецификацию;
|
||||||
|
3. передать текущую спецификацию и текст очередного документа в LLM;
|
||||||
|
4. получить операции `ADD`, `UPDATE`, `DELETE`, `UNRESOLVED` или режим `full_replace`;
|
||||||
|
5. корректно транслировать ссылки LLM на строки текущей спецификации;
|
||||||
|
6. применить операции к БД и сохранить историю событий;
|
||||||
|
7. отдать прогресс и результат через SSE;
|
||||||
|
8. показать пользователю фактический итог сверки.
|
||||||
|
|
||||||
|
Особенно проверь, что система не выдаёт успешный результат, если часть операций или документов фактически не обработана.
|
||||||
|
|
||||||
|
## Файлы для обязательного изучения
|
||||||
|
|
||||||
|
Изучи только эти файлы и их прямые зависимости, необходимые для понимания сверки:
|
||||||
|
|
||||||
|
1. `contracts-flask/site/services/process.py`
|
||||||
|
- основной pipeline сверки;
|
||||||
|
- порядок документов;
|
||||||
|
- получение текущей спецификации;
|
||||||
|
- подготовка текста документа;
|
||||||
|
- вызов LLM;
|
||||||
|
- трансляция `target_id` в `target_hash`;
|
||||||
|
- обработка `full_replace`;
|
||||||
|
- применение операций;
|
||||||
|
- арифметическая проверка;
|
||||||
|
- SSE-события логического pipeline.
|
||||||
|
|
||||||
|
2. `contracts-flask/site/routes/pipeline_bp.py`
|
||||||
|
- endpoint `GET /process-v2`;
|
||||||
|
- генерация SSE;
|
||||||
|
- heartbeat;
|
||||||
|
- закрытие/обрыв соединения;
|
||||||
|
- преобразование исключений в SSE-события;
|
||||||
|
- различие HTTP-ошибки до начала стрима и ошибки внутри стрима.
|
||||||
|
|
||||||
|
3. `contracts-flask/site/static/compare.js`
|
||||||
|
- `startCompareSSE()`;
|
||||||
|
- обработка `extract_start`, `llm_done`, `applied`, `extract_error`, `apply_error`, `done`, `error`;
|
||||||
|
- обработка `EventSource.onerror`;
|
||||||
|
- закрытие EventSource;
|
||||||
|
- соответствие frontend-событий backend-событиям.
|
||||||
|
|
||||||
|
4. `contracts-flask/site/static/app.js`
|
||||||
|
- функции запуска сверки группы;
|
||||||
|
- вызов `/api/apply-groups`;
|
||||||
|
- получение `contract_id`;
|
||||||
|
- запуск `/process-v2`;
|
||||||
|
- обработка success/error/connection error;
|
||||||
|
- изменение состояния группы после завершения.
|
||||||
|
|
||||||
|
5. `contracts-flask/site/services/llm.py`
|
||||||
|
- фактический вызов LLM для сверки;
|
||||||
|
- формат prompt и ответа;
|
||||||
|
- timeout/retry;
|
||||||
|
- обработка невалидного ответа;
|
||||||
|
- возможная потеря или искажение операций.
|
||||||
|
|
||||||
|
6. `contracts-flask/site/llm_prompt.py`
|
||||||
|
- prompt сверки;
|
||||||
|
- формат текущей спецификации;
|
||||||
|
- формат ожидаемых операций;
|
||||||
|
- ограничения для `ADD`, `UPDATE`, `DELETE`, `UNRESOLVED`, `full_replace`.
|
||||||
|
|
||||||
|
7. `contracts-flask/site/db/spec_events.py`
|
||||||
|
- `reset()`;
|
||||||
|
- `clear_current()`;
|
||||||
|
- `apply_ops()`;
|
||||||
|
- транзакции и атомарность;
|
||||||
|
- сохранение истории;
|
||||||
|
- статусы применённых и неразрешённых операций;
|
||||||
|
- поведение при частичной ошибке.
|
||||||
|
|
||||||
|
8. `contracts-flask/site/db/spec_current.py`
|
||||||
|
- получение текущих строк спецификации;
|
||||||
|
- идентификаторы и `name_hash`;
|
||||||
|
- получение `elements_json`;
|
||||||
|
- согласованность данных между текущим состоянием и историей.
|
||||||
|
|
||||||
|
9. `contracts-flask/site/db/supplements.py`
|
||||||
|
- только функции, используемые `process.py` для получения документов уже сформированной группы;
|
||||||
|
- порядок и фильтрация документов;
|
||||||
|
- отсутствие/дублирование документов.
|
||||||
|
|
||||||
|
10. `contracts-flask/site/services/metrics.py`
|
||||||
|
- `check_arithmetic()`;
|
||||||
|
- что именно проверяется;
|
||||||
|
- может ли ошибка арифметики повлиять на результат;
|
||||||
|
- почему проверка не должна маскировать ошибку применения.
|
||||||
|
|
||||||
|
## Тесты для обязательного изучения
|
||||||
|
|
||||||
|
1. `contracts-flask/tests/test_process_pipeline.py`
|
||||||
|
- какие сценарии реально покрыты;
|
||||||
|
- корректность `full_replace`;
|
||||||
|
- корректность трансляции `target_id`;
|
||||||
|
- отсутствие тестов на реальные SSE и ошибки LLM.
|
||||||
|
|
||||||
|
2. `contracts-flask/tests/test_grouping.py`
|
||||||
|
- только часть, необходимая для понимания структуры группы, передаваемой в `apply_groups`.
|
||||||
|
|
||||||
|
3. `contracts-flask/tests/test_metrics.py`
|
||||||
|
- только тесты арифметической проверки, относящиеся к операциям сверки.
|
||||||
|
|
||||||
|
4. Найди все остальные тесты, которые напрямую вызывают `run_pipeline`, `apply_ops`, `process-v2` или `startCompareSSE`, и включи их в анализ только если они действительно относятся к собственно сверке.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
|
||||||
|
### 1. Корректность алгоритма сверки
|
||||||
|
|
||||||
|
- Не теряется ли первая спецификация или базовое состояние.
|
||||||
|
- Правильно ли строится `current_spec` перед каждым следующим документом.
|
||||||
|
- Гарантирован ли правильный порядок обработки документов.
|
||||||
|
- Не зависит ли порядок от нестабильного `created_at` или формата даты.
|
||||||
|
- Корректно ли работает переход между несколькими документами группы.
|
||||||
|
- Не дублируются ли строки при повторном запуске.
|
||||||
|
- Корректно ли работает `full_replace` при уже существующих строках.
|
||||||
|
- Может ли `full_replace` удалить корректные данные при ошибочном/неполном ответе LLM.
|
||||||
|
|
||||||
|
### 2. Операции LLM
|
||||||
|
|
||||||
|
- Как `target_id` переводится в фактический идентификатор строки.
|
||||||
|
- Что происходит при `r0`, `r999`, `rX`, отсутствующем `target_id`, неверном `target_hash`.
|
||||||
|
- Не может ли LLM обновить не ту строку из-за изменения порядка строк.
|
||||||
|
- Что происходит с неизвестными действиями.
|
||||||
|
- Что происходит с отсутствующими полями `name`, `price`, `qty`, `sum`, `date_start`.
|
||||||
|
- Не приводит ли частично валидная операция к тихой потере данных.
|
||||||
|
- Сохраняется ли исходный ответ LLM и prompt для аудита.
|
||||||
|
- Как обрабатываются пустой, обрезанный, markdown-обёрнутый или частично повреждённый JSON-ответ.
|
||||||
|
|
||||||
|
### 3. БД, транзакции и атомарность
|
||||||
|
|
||||||
|
- Атомарно ли применяются операции одного документа.
|
||||||
|
- Что происходит, если пятая операция из десяти падает.
|
||||||
|
- Может ли current state измениться, а event history не сохраниться, или наоборот.
|
||||||
|
- Не приводит ли `reset()` к потере истории при повторном запуске.
|
||||||
|
- Различаются ли `applied`, `unresolved`, `failed` и действительно ли эти статусы отражают результат.
|
||||||
|
- Безопасен ли параллельный запуск двух сравнений одного договора.
|
||||||
|
- Безопасен ли одновременный запуск сравнений разных групп.
|
||||||
|
- Есть ли race condition между `apply-groups`, `/process-v2` и frontend state.
|
||||||
|
|
||||||
|
### 4. SSE и сетевые ошибки
|
||||||
|
|
||||||
|
- Совпадает ли событие завершения backend (`complete` или `done`) с событием, которое ожидает frontend.
|
||||||
|
- Может ли frontend навсегда оставить сравнение в состоянии `⏳`.
|
||||||
|
- Что происходит при disconnect после `applied`, но до события завершения.
|
||||||
|
- Что происходит при heartbeat без данных.
|
||||||
|
- Может ли `EventSource.onerror` ошибочно объявить ошибку после нормального закрытия.
|
||||||
|
- Показывает ли UI частичный результат как полный.
|
||||||
|
- Есть ли таймаут на клиенте и сервере.
|
||||||
|
- Возвращается ли пользователю причина ошибки LLM/API, а не только «Ошибка соединения».
|
||||||
|
- Не теряются ли последние SSE-события из-за proxy buffering.
|
||||||
|
|
||||||
|
### 5. Числовая и предметная корректность
|
||||||
|
|
||||||
|
- Проверяется ли арифметика `price * qty = sum` до применения и после применения.
|
||||||
|
- Как обрабатываются `None`, строки с запятой, целые/дробные числа, отрицательные значения и большие числа.
|
||||||
|
- Не изменяет ли арифметическая проверка данные или только логирует ошибку.
|
||||||
|
- Может ли LLM вернуть арифметически неверную операцию, которая всё равно попадёт в current state.
|
||||||
|
- Сохраняется ли точность Decimal/float.
|
||||||
|
- Как обрабатываются даты и отсутствие даты.
|
||||||
|
|
||||||
|
### 6. Повторяемость и идемпотентность
|
||||||
|
|
||||||
|
- Что произойдёт при повторном нажатии «Сравнить эту группу».
|
||||||
|
- Можно ли безопасно повторить сравнение после сетевого обрыва.
|
||||||
|
- Будут ли повторно добавлены те же строки.
|
||||||
|
- Как отделяется новый запуск от предыдущей истории.
|
||||||
|
- Есть ли идентификатор запуска и защита от повторного применения одного ответа.
|
||||||
|
|
||||||
|
## Обязательные вопросы от ревьюера
|
||||||
|
|
||||||
|
Ответь отдельно на следующие вопросы, даже если для ответа придётся изучить прямую зависимость:
|
||||||
|
|
||||||
|
1. Почему в реальном тесте один допник смог попасть в группу без базового договора, и может ли собственно pipeline сверки безопасно обработать такую неполную группу?
|
||||||
|
2. При каком именно условии `run_pipeline()` считает сверку успешной?
|
||||||
|
3. Может ли pipeline отправить `complete`, если один документ дал `extract_error` или часть операций не применилась?
|
||||||
|
4. Почему backend использует событие `complete`, а frontend-код может ожидать `done`? Это реальный баг или только устаревший комментарий/другая ветка?
|
||||||
|
5. Если LLM вернул `full_replace` с пустым `ops`, будет ли текущая спецификация очищена? Должно ли так происходить?
|
||||||
|
6. Если `apply_ops()` применил только часть операций, как это отражается в SSE и UI?
|
||||||
|
7. Есть ли транзакция, гарантирующая согласованность `spec_current` и `spec_events`?
|
||||||
|
8. Можно ли повторно запустить `/process-v2` для того же `contract_id` без дублирования или повреждения результата?
|
||||||
|
9. Что происходит при одновременном сравнении двух групп, относящихся к одному договору?
|
||||||
|
10. Как система отличает «LLM вернул корректный пустой результат» от «LLM вернул повреждённый/неполный ответ»?
|
||||||
|
11. Какие операции считаются `UNRESOLVED`, где они сохраняются и видит ли их пользователь?
|
||||||
|
12. Может ли `check_arithmetic()` обнаружить ошибку, но pipeline всё равно завершиться успешно?
|
||||||
|
13. Какой минимальный набор интеграционных тестов нужен для доказательства корректности реальной сверки?
|
||||||
|
14. Какие риски остаются именно в собственно сверке после исключения upload/classify/grouping из области анализа?
|
||||||
|
|
||||||
|
## Формат отчёта
|
||||||
|
|
||||||
|
Пиши отчёт в формате code review.
|
||||||
|
|
||||||
|
Сначала findings, отсортированные по серьёзности:
|
||||||
|
|
||||||
|
- `BLOCKER` — возможна потеря/порча данных или ложный успешный результат сверки;
|
||||||
|
- `HIGH` — неверное применение операций, нарушение атомарности, повторное применение или потеря результата;
|
||||||
|
- `MEDIUM` — ошибочное отображение статуса, частичный результат, нестабильность или отсутствие важной защиты;
|
||||||
|
- `LOW` — локальная проблема качества, диагностики или сопровождаемости.
|
||||||
|
|
||||||
|
Для каждого finding укажи:
|
||||||
|
|
||||||
|
- severity;
|
||||||
|
- файл и конкретный символ/участок кода;
|
||||||
|
- точную последовательность, которая приводит к проблеме;
|
||||||
|
- воспроизводимый сценарий;
|
||||||
|
- фактический ущерб;
|
||||||
|
- минимальное исправление;
|
||||||
|
- обязательный тест.
|
||||||
|
|
||||||
|
Не предлагай изменения вне области собственно сверки. Не исправляй код самостоятельно. Не делай общий обзор всего проекта. Если findings нет, напиши это явно и перечисли оставшиеся пробелы тестирования.
|
||||||
|
|
||||||
|
## Жёсткое ограничение по стоимости
|
||||||
|
|
||||||
|
Не пиши код, патчи, diff и готовые реализации. Не изменяй файлы и не выполняй команды. Твоя задача — только code review: факты, findings, доказательства, вопросы и минимальные рекомендации словами. Любые предлагаемые исправления описывай концептуально, без реализации.
|
||||||
|
|
||||||
|
В конце добавь:
|
||||||
|
|
||||||
|
1. ответы на 14 обязательных вопросов;
|
||||||
|
2. таблицу покрытия тестами;
|
||||||
|
3. минимальный план исправлений, если они нужны;
|
||||||
|
4. список файлов, которые действительно были изучены.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Критическая перепроверка ответа Соннета: pipeline сверки
|
||||||
|
|
||||||
|
Дата: 2026-08-27
|
||||||
|
Основание: ответ Соннета из `History/sonnet-review-comparison-answer-2026-08-27.md`
|
||||||
|
|
||||||
|
## Подтверждено по исходникам
|
||||||
|
|
||||||
|
### B-1: `complete` против `done`
|
||||||
|
|
||||||
|
Подтверждено.
|
||||||
|
|
||||||
|
- `site/services/process.py` завершает `run_pipeline()` событием `{"type": "complete"}`.
|
||||||
|
- `site/static/compare.js` завершает успешный SSE только в ветке `d.type === 'done'`.
|
||||||
|
- При нормальном закрытии генератора после неизвестного события клиент получает `EventSource.onerror`, поэтому успешная сверка может отображаться как ошибка соединения.
|
||||||
|
|
||||||
|
Это не предположение Соннета, а прямое несоответствие backend/frontend event contract.
|
||||||
|
|
||||||
|
### B-2: отдельные commits в `apply_ops()`
|
||||||
|
|
||||||
|
Основная часть finding подтверждена.
|
||||||
|
|
||||||
|
- `site/db/spec_events.py::apply_ops()` вызывает `execute()` для каждого события и изменения current state.
|
||||||
|
- `site/db/connection.py::execute()` делает `conn.commit()` после каждого SQL-вызова.
|
||||||
|
- В `apply_ops()` нет единой транзакции и rollback.
|
||||||
|
- Поэтому исключение после уже выполненных операций оставляет предыдущие commits в БД.
|
||||||
|
- `process.py` после исключения формирует нулевую summary и не показывает уже применённые операции как applied.
|
||||||
|
|
||||||
|
Требует отдельной проверки формулировка о том, что history и current state обязательно расходятся при каждом сценарии: это зависит от того, на каком именно SQL-вызове возникает исключение. Но частичный commit и недостоверная summary подтверждены.
|
||||||
|
|
||||||
|
### B-3: stale `name_hash`
|
||||||
|
|
||||||
|
Подтверждено.
|
||||||
|
|
||||||
|
`site/db/spec_events.py::_update_spec_current()` обновляет `name`, `price`, `qty`, `sum`, `date_start`, но не пересчитывает и не обновляет `name_hash`. При последующем ADD с новым именем `_hash()` создаёт другой ключ, поэтому сценарий с дублированием реален.
|
||||||
|
|
||||||
|
Нужно отдельно проверить бизнес-правило для изменения `date_start`: изменение даты может означать новый период и не во всех случаях должно менять identity строки. Автоматически объединять `name` и `date_start` в одно исправление нельзя без подтверждения модели идентичности.
|
||||||
|
|
||||||
|
### H-1: `seq` для неизвестного action
|
||||||
|
|
||||||
|
Подтверждено.
|
||||||
|
|
||||||
|
В ветке `else` `apply_ops()` вызывает `_log_unresolved(...)`, но не делает `seq += 1`. Следующая операция получает тот же `seq`.
|
||||||
|
|
||||||
|
### H-2: `unresolved` недоступен в summary
|
||||||
|
|
||||||
|
Подтверждено по текущим участкам.
|
||||||
|
|
||||||
|
`apply_ops()` возвращает только `added`, `updated`, `deleted`. При этом frontend использует `d.total_unresolved`, а `run_pipeline()` не формирует это поле в финальном событии.
|
||||||
|
|
||||||
|
Требует проверки полный путь отображения `UNRESOLVED`: факт отсутствия счётчика в summary подтверждён, но следует проверить, нет ли другого endpoint/UI, который показывает события напрямую.
|
||||||
|
|
||||||
|
### H-3: результат `check_arithmetic()` игнорируется
|
||||||
|
|
||||||
|
Подтверждено для `run_pipeline()`.
|
||||||
|
|
||||||
|
`process.py` вызывает `check_arithmetic(ops)` и игнорирует возвращаемое значение. Требуется дополнительно проверить, логирует ли сама функция несоответствия и является ли её контракт предупреждением или валидатором, прежде чем выбирать severity и формат исправления.
|
||||||
|
|
||||||
|
### H-4: пустой `full_replace`
|
||||||
|
|
||||||
|
Подтверждено по порядку операций.
|
||||||
|
|
||||||
|
В `process.py` `clear_current(contract_id)` вызывается после получения `mode` и до `apply_ops()`, без проверки непустого `ops`. Пустой список при `mode == 'full_replace'` очищает current state.
|
||||||
|
|
||||||
|
Нужно уточнить у Соннета, какие именно ответы считать повреждёнными: пустой `ops` может быть штатным ответом для пустой спецификации, хотя для существующего current state это опасный случай.
|
||||||
|
|
||||||
|
### M-1: финальное событие после ошибок
|
||||||
|
|
||||||
|
Подтверждено.
|
||||||
|
|
||||||
|
После цикла `run_pipeline()` безусловно выдаёт финальное событие, даже если каждый supplement завершился `extract_error`.
|
||||||
|
|
||||||
|
## Подтверждено частично или требует дополнительных доказательств
|
||||||
|
|
||||||
|
### M-2: concurrency
|
||||||
|
|
||||||
|
Риск правдоподобен, но формулировку о конкретных коллизиях `seq` нужно доказать тестом. `WAL` сериализует записи, но `get_next_seq()` и последующие операции разделены во времени. Нужен воспроизводимый конкурентный тест с двумя pipeline на одном `contract_id`.
|
||||||
|
|
||||||
|
### M-3: SSE timeout
|
||||||
|
|
||||||
|
Отсутствие клиентского timeout видно, но само по себе не доказывает пользовательский дефект: сервер отправляет heartbeat каждые 15 секунд. Нужно проверить proxy timeout, лимит длительности LLM и поведение при остановленном backend.
|
||||||
|
|
||||||
|
### M-4: `last_event_id`
|
||||||
|
|
||||||
|
Подтверждена причина риска: `spec_current` вставляется без `last_event_id`, а UPDATE также его не заполняет; cleanup в `supplements.delete_by_document()` фильтрует по этому полю. Нужен тест удаления supplement после сверки, чтобы окончательно подтвердить наблюдаемое поведение.
|
||||||
|
|
||||||
|
### L-1: сортировка
|
||||||
|
|
||||||
|
Подтверждено, что `list_by_contract()` не выбирает `doc_date` и `created_at` как поля результата, поэтому ключ сортировки в `process.py` фактически использует значения по умолчанию. При этом SQL уже сортирует по `s.created_at`, поэтому это скорее misleading code, а не доказанная поломка порядка.
|
||||||
|
|
||||||
|
### L-2: пустой current state
|
||||||
|
|
||||||
|
Технически возможно, но вывод о неправильном prompt зависит от семантики группы и контракта `build_prompt()`. Нужна точная проверка prompt и отдельный сценарий сбоя/неполной группы.
|
||||||
|
|
||||||
|
## Дополнительные вопросы Соннету
|
||||||
|
|
||||||
|
1. Для B-2: укажи точный SQL-вызов и сценарий исключения, при котором расходятся `spec_events` и `spec_current`; отдельно различи частичный commit и рассогласование двух таблиц.
|
||||||
|
2. Для B-3: должна ли смена `name` менять identity строки, или `name_hash` является историческим ключом? Какие правила действуют при одновременной смене `name` и `date_start`?
|
||||||
|
3. Для H-2: где именно пользователь должен видеть `UNRESOLVED`, если не через summary? Укажи полный frontend/backend путь и проверяемый сценарий.
|
||||||
|
4. Для H-3: что возвращает `check_arithmetic()` и есть ли у него побочный logging? Приведи фактический пример mismatch и ожидаемый бизнес-статус.
|
||||||
|
5. Для H-4: почему пустой `full_replace` однозначно считать повреждённым ответом, если документ может содержать пустую спецификацию? Как отличить штатный результат от обрыва/невалидного JSON?
|
||||||
|
6. Для M-2: предоставь воспроизводимый тест или timeline с двумя потоками, который приводит к одинаковому `seq` или повреждённому current state.
|
||||||
|
7. Для M-3: какой фактический timeout установлен на nginx/proxy и как он соотносится с heartbeat и максимальным временем обработки группы?
|
||||||
|
8. Для M-4: есть ли штатный путь удаления supplement после сверки, и должен ли он удалять строки current state, если строка затронута несколькими supplements?
|
||||||
|
9. Для L-2: приведи точный контракт `extract`/`diff` prompt и доказательство, что пустой current state после ошибки действительно меняет смысл следующего LLM-вызова.
|
||||||
|
10. Какие findings Соннет считает подтверждёнными тестом, а какие являются только статическим риском?
|
||||||
|
11. Проверь финальное событие по реальному frontend-коду: нет ли другой ветки, которая обрабатывает `complete` или завершение `EventSource` без `done`?
|
||||||
|
12. Для каждого BLOCKER укажи минимальный regression test, который сначала падает на текущей версии и проходит после исправления.
|
||||||
|
|
||||||
|
## Предварительный вывод
|
||||||
|
|
||||||
|
Без дополнительных ответов Соннета уже достаточно доказательств для регистрации B-1, B-2, B-3, H-1, H-2, H-3, H-4 и M-1 как реальных проблем текущего кода. M-2, M-3, M-4 и L-2 требуют воспроизводимых тестов или более точной трассировки. L-1 подтверждён как избыточная/вводящая в заблуждение сортировка, но не как текущая поломка порядка документов.
|
||||||
|
|
||||||
|
Изменения production-кода по этим findings не выполнялись.
|
||||||
|
|
||||||
|
## Обязательное ограничение для дальнейшего общения с Соннетом
|
||||||
|
|
||||||
|
Соннет не должен писать код, patch или diff и не должен изменять файлы. Нужно запрашивать только критический анализ, проверяемые доказательства, воспроизводимые сценарии, тестовые идеи в виде описания и дополнительные вопросы. Реализацию выполняем отдельно после собственной проверки findings.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""Shared fixtures for pytest — contracts-flask decoupling tests."""
|
|
||||||
import pytest
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Ensure deploy/ is on path for imports
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
|
||||||
|
|
||||||
from compare.llm_client import FakeLLMClient
|
|
||||||
from repository import MemRepository
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_llm():
|
|
||||||
"""Fake LLM client with pre-registered responses."""
|
|
||||||
client = FakeLLMClient()
|
|
||||||
client.add("default", '{"doc_type":"contract","own_number":"03700_1","doc_date":"2026-02-01","counterparty":"ЗАО XXX001"}')
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mem_repo():
|
|
||||||
"""In-memory repository for unit tests."""
|
|
||||||
return MemRepository()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_docx_bytes():
|
|
||||||
"""Minimal test bytes — not a real DOCX, just for multipart tests."""
|
|
||||||
return b"FAKE_DOCX_CONTENT"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_classify_response():
|
|
||||||
"""Sample LLM classify response."""
|
|
||||||
return {
|
|
||||||
"doc_type": "contract",
|
|
||||||
"own_number": "03700_1",
|
|
||||||
"parent_number": None,
|
|
||||||
"doc_date": "2026-02-01",
|
|
||||||
"counterparty": "ЗАО XXX001",
|
|
||||||
}
|
|
||||||
@@ -207,6 +207,8 @@ class TwoPassObfuscator:
|
|||||||
"""
|
"""
|
||||||
# --- Распаковать ZIP-файлы ---
|
# --- Распаковать ZIP-файлы ---
|
||||||
files = self._expand_zips(files)
|
files = self._expand_zips(files)
|
||||||
|
# --- Конвертировать PDF → DOCX ---
|
||||||
|
files = self._convert_pdfs_to_docx(files)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# --- Проход 1: сбор сущностей ---
|
# --- Проход 1: сбор сущностей ---
|
||||||
@@ -236,10 +238,6 @@ class TwoPassObfuscator:
|
|||||||
pass
|
pass
|
||||||
elif fname in all_docx:
|
elif fname in all_docx:
|
||||||
obf_content = self._replace_in_docx(all_docx[fname])
|
obf_content = self._replace_in_docx(all_docx[fname])
|
||||||
elif fname.endswith('.pdf'):
|
|
||||||
txt = all_texts.get(fname, '')
|
|
||||||
obf_content = self._replace_in_text(txt, fname)
|
|
||||||
fname = fname[:-4] + '.txt'
|
|
||||||
else:
|
else:
|
||||||
txt = all_texts.get(fname, '')
|
txt = all_texts.get(fname, '')
|
||||||
obf_content = self._replace_in_text(txt, fname)
|
obf_content = self._replace_in_text(txt, fname)
|
||||||
@@ -253,6 +251,49 @@ class TwoPassObfuscator:
|
|||||||
self._regex_replacements.clear()
|
self._regex_replacements.clear()
|
||||||
self._sorted_keys.clear()
|
self._sorted_keys.clear()
|
||||||
|
|
||||||
|
def _convert_pdfs_to_docx(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
|
||||||
|
"""Конвертировать PDF в DOCX через pdfplumber. При совпадении имён — _из_pdf."""
|
||||||
|
import pdfplumber
|
||||||
|
from docx import Document as DocxDocument
|
||||||
|
result = []
|
||||||
|
existing_names = {f[0] for f in files}
|
||||||
|
for fname, content, ctype in files:
|
||||||
|
if not fname.lower().endswith('.pdf'):
|
||||||
|
result.append((fname, content, ctype))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
doc = DocxDocument()
|
||||||
|
with pdfplumber.open(io.BytesIO(content)) as pdf:
|
||||||
|
for page in pdf.pages:
|
||||||
|
tables = page.extract_tables()
|
||||||
|
for table in tables:
|
||||||
|
if table:
|
||||||
|
rows = [[str(c or "").strip() for c in (row or [])] for row in table]
|
||||||
|
rows = [r for r in rows if any(r)]
|
||||||
|
if rows:
|
||||||
|
t = doc.add_table(rows=len(rows), cols=len(rows[0]))
|
||||||
|
t.style = 'Table Grid'
|
||||||
|
for ri, row in enumerate(rows):
|
||||||
|
for ci, cell_text in enumerate(row):
|
||||||
|
t.rows[ri].cells[ci].text = cell_text
|
||||||
|
text = page.extract_text()
|
||||||
|
if text:
|
||||||
|
for line in text.split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
doc.add_paragraph(line)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
doc.save(buf)
|
||||||
|
new_name = fname[:-4] + '.docx'
|
||||||
|
if new_name in existing_names:
|
||||||
|
new_name = fname[:-4] + '_из_pdf.docx'
|
||||||
|
existing_names.add(new_name)
|
||||||
|
result.append((new_name, buf.getvalue(), ctype))
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("PDF→DOCX error for %s: %s", fname, e)
|
||||||
|
result.append((fname, content, ctype))
|
||||||
|
return result
|
||||||
|
|
||||||
def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
|
def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
|
||||||
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть.
|
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть.
|
||||||
Защита от ZIP-бомб: ratio + накопительный размер (как в compare/unzip.py)."""
|
Защита от ZIP-бомб: ratio + накопительный размер (как в compare/unzip.py)."""
|
||||||
|
|||||||
@@ -92,6 +92,22 @@ server {
|
|||||||
client_max_body_size 100m;
|
client_max_body_size 100m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── VM-буфер загрузки contracts (паттерн drhider) ──────────────
|
||||||
|
# Браузер кладёт файл сюда PUT (мимо шлюза кластера ~64КБ), бэк тянет сам.
|
||||||
|
# Файлы: /var/www/contracts-upload/ (нужно создать, chown www-data).
|
||||||
|
# CORS: origin фронтенда = contractor.pythonk8s.dev.nubes.ru (ingress, подтверждено).
|
||||||
|
location /contracts-upload/ {
|
||||||
|
alias /var/www/contracts-upload/;
|
||||||
|
dav_methods PUT DELETE;
|
||||||
|
create_full_put_path on;
|
||||||
|
client_max_body_size 1024m;
|
||||||
|
add_header Access-Control-Allow-Origin https://contractor.pythonk8s.dev.nubes.ru always;
|
||||||
|
add_header Access-Control-Allow-Methods 'PUT, GET, OPTIONS, DELETE' always;
|
||||||
|
add_header Access-Control-Allow-Headers 'Content-Type' always;
|
||||||
|
add_header Access-Control-Max-Age 86400 always;
|
||||||
|
if ($request_method = OPTIONS) { return 204; }
|
||||||
|
}
|
||||||
|
|
||||||
listen 443 ssl; # managed by Certbot
|
listen 443 ssl; # managed by Certbot
|
||||||
ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot
|
ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot
|
||||||
ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot
|
ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot
|
||||||
|
|||||||
-532
@@ -1,532 +0,0 @@
|
|||||||
/**
|
|
||||||
* tests.js — Тесты чистых функций (Фазы 0-4, decoupling-final-plan.md).
|
|
||||||
*
|
|
||||||
* Запуск: node tests.js
|
|
||||||
*
|
|
||||||
* Проверяет: statusToHTML, applyParseResult, reconcileSelection,
|
|
||||||
* renderGroupCard, renderGroupCardDone, renderUnresolvedCard,
|
|
||||||
* applyCompareEvent, renderCompareSectionHeader, renderCompareOpsTable,
|
|
||||||
* renderCompareSectionBody.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── Моки глобальных переменных ──────────────────────────────
|
|
||||||
|
|
||||||
// Браузерные глобалы, нужные модулям при загрузке
|
|
||||||
global.window = global; // window.* присваивания
|
|
||||||
|
|
||||||
// Мок document
|
|
||||||
global.document = {
|
|
||||||
getElementById: function(id) { return null; },
|
|
||||||
createElement: function(tag) {
|
|
||||||
return {
|
|
||||||
style: {},
|
|
||||||
classList: { add: function(){}, remove: function(){} },
|
|
||||||
innerHTML: '',
|
|
||||||
textContent: '',
|
|
||||||
appendChild: function(){},
|
|
||||||
querySelector: function(){ return null; },
|
|
||||||
querySelectorAll: function(){ return []; },
|
|
||||||
addEventListener: function(){},
|
|
||||||
parentNode: { insertBefore: function(){} },
|
|
||||||
nextSibling: null
|
|
||||||
};
|
|
||||||
},
|
|
||||||
querySelector: function() { return null; }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Мок crypto.randomUUID
|
|
||||||
global.crypto = { randomUUID: function() { return 'test-uuid-' + Date.now(); } };
|
|
||||||
|
|
||||||
// Мок lucide
|
|
||||||
global.lucide = { createIcons: function(){} };
|
|
||||||
|
|
||||||
// Глобальные переменные приложения
|
|
||||||
global.fileInput = { disabled: false, value: '', addEventListener: function(){}, files: [] };
|
|
||||||
global.fileTable = { innerHTML: '' };
|
|
||||||
global.VM_API = 'https://contracts.kube5s.ru';
|
|
||||||
global.UPLOAD_URL = VM_API + '/upload';
|
|
||||||
global.CONVERT_URL = VM_API + '/convert-doc';
|
|
||||||
global.UNZIP_URL = VM_API + '/unzip-upload';
|
|
||||||
global.SITE_URL = '';
|
|
||||||
|
|
||||||
var state = {
|
|
||||||
files: [],
|
|
||||||
contractId: null,
|
|
||||||
batchId: 'test-batch-id',
|
|
||||||
groups: null,
|
|
||||||
_activeCompare: { es: null, timer: null },
|
|
||||||
ui: { steps: { upload: '○', classify: '○', groups: '○', compare: '○' } }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Мок escHtml (из app_utils.js)
|
|
||||||
global.escHtml = function(s) {
|
|
||||||
if (s == null) return '';
|
|
||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
||||||
};
|
|
||||||
|
|
||||||
var passed = 0, failed = 0;
|
|
||||||
|
|
||||||
function assert(cond, msg) {
|
|
||||||
if (cond) { passed++; }
|
|
||||||
else { console.log(' FAIL: ' + msg); failed++; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function eq(actual, expected, msg) {
|
|
||||||
if (actual === expected) { passed++; }
|
|
||||||
else { console.log(' FAIL: ' + msg + ' — expected ' + JSON.stringify(expected) + ', got ' + JSON.stringify(actual)); failed++; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function contains(str, substr, msg) {
|
|
||||||
if (str.indexOf(substr) !== -1) { passed++; }
|
|
||||||
else { console.log(' FAIL: ' + msg + ' — string does not contain "' + substr + '"'); console.log(' got: ' + str.substring(0, 200)); failed++; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Загружаем модули ─────────────────────────────────────────
|
|
||||||
// Модули используют function declaration (глобальные в браузере).
|
|
||||||
// В Node.js загружаем через eval в глобальном контексте.
|
|
||||||
|
|
||||||
var fs = require('fs');
|
|
||||||
|
|
||||||
function loadModule(path) {
|
|
||||||
var code = fs.readFileSync(path, 'utf-8');
|
|
||||||
// (0, eval) — indirect eval, выполняется в глобальном скоупе (не strict)
|
|
||||||
(0, eval)(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('=== Загрузка модулей ===');
|
|
||||||
loadModule('./files.js');
|
|
||||||
console.log(' files.js — OK');
|
|
||||||
loadModule('./groups.js');
|
|
||||||
console.log(' groups.js — OK');
|
|
||||||
loadModule('./compare.js');
|
|
||||||
console.log(' compare.js — OK');
|
|
||||||
|
|
||||||
// ── Тесты: statusToHTML ──────────────────────────────────────
|
|
||||||
console.log('\n=== statusToHTML ===');
|
|
||||||
|
|
||||||
eq(statusToHTML(null), '', 'null → пусто');
|
|
||||||
eq(statusToHTML({}), '', 'пустой объект → пусто');
|
|
||||||
eq(statusToHTML({ kind: '' }), '', 'пустой kind → пусто');
|
|
||||||
eq(statusToHTML({ kind: 'uploading', pct: 0 }), '↑ 0%', 'uploading 0%');
|
|
||||||
eq(statusToHTML({ kind: 'uploading', pct: 75 }), '↑ 75%', 'uploading 75%');
|
|
||||||
eq(statusToHTML({ kind: 'uploaded' }), '<span class="status-ok">✓</span>', 'uploaded');
|
|
||||||
eq(statusToHTML({ kind: 'unzipping' }), '⏳ распаковка...', 'unzipping');
|
|
||||||
eq(statusToHTML({ kind: 'parsing' }), '⏳ парсинг...', 'parsing');
|
|
||||||
eq(statusToHTML({ kind: 'parsed', count: 5 }), '<span class="status-ok">✓ 5 эл.</span>', 'parsed без elapsed');
|
|
||||||
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '2.3' }), '<span class="status-ok">✓ 5 эл. (2.3с)</span>', 'parsed с elapsed');
|
|
||||||
eq(statusToHTML({ kind: 'error', text: 'тест' }), '<span class="status-err">✗ тест</span>', 'error с текстом');
|
|
||||||
eq(statusToHTML({ kind: 'error' }), '<span class="status-err">✗ Неизвестная ошибка</span>', 'error без текста');
|
|
||||||
|
|
||||||
// Краевые случаи
|
|
||||||
eq(statusToHTML(undefined), '', 'undefined → пусто');
|
|
||||||
eq(statusToHTML({ kind: 'uploading' }), '↑ 0%', 'uploading без pct → 0%');
|
|
||||||
eq(statusToHTML({ kind: 'uploading', pct: -5 }), '↑ -5%', 'uploading отрицательный pct');
|
|
||||||
eq(statusToHTML({ kind: 'uploading', pct: 100 }), '↑ 100%', 'uploading 100%');
|
|
||||||
eq(statusToHTML({ kind: 'parsed', count: 0 }), '<span class="status-ok">✓ 0 эл.</span>', 'parsed count=0');
|
|
||||||
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '0' }), '<span class="status-ok">✓ 5 эл. (0с)</span>', 'parsed elapsed=0');
|
|
||||||
eq(statusToHTML({ kind: 'error', text: '' }), '<span class="status-err">✗ Неизвестная ошибка</span>', 'error с пустым текстом → дефолт');
|
|
||||||
eq(statusToHTML({ kind: 'unknown_kind' }), '', 'неизвестный kind → пусто');
|
|
||||||
|
|
||||||
// ── Тесты: applyParseResult ──────────────────────────────────
|
|
||||||
console.log('\n=== applyParseResult ===');
|
|
||||||
|
|
||||||
// parsed success
|
|
||||||
var e1 = {};
|
|
||||||
applyParseResult(e1, { status: 'parsed', element_count: 10 }, '1.5');
|
|
||||||
assert(e1.parsed === true, 'parsed=true');
|
|
||||||
assert(e1.parseInfo.element_count === 10, 'parseInfo сохранён');
|
|
||||||
eq(e1.status.kind, 'parsed', 'status.kind=parsed');
|
|
||||||
eq(e1.status.count, 10, 'status.count=10');
|
|
||||||
eq(e1.status.elapsed, '1.5', 'status.elapsed=1.5');
|
|
||||||
|
|
||||||
// parsed without elapsed
|
|
||||||
var e2 = {};
|
|
||||||
applyParseResult(e2, { status: 'parsed', element_count: 3 });
|
|
||||||
eq(e2.status.kind, 'parsed', 'без elapsed: kind=parsed');
|
|
||||||
eq(e2.status.count, 3, 'без elapsed: count=3');
|
|
||||||
assert(e2.status.elapsed === undefined, 'без elapsed: elapsed отсутствует');
|
|
||||||
|
|
||||||
// error
|
|
||||||
var e3 = {};
|
|
||||||
applyParseResult(e3, { status: 'error', error: 'битый PDF' });
|
|
||||||
eq(e3.status.kind, 'error', 'error: kind=error');
|
|
||||||
eq(e3.status.text, 'битый PDF', 'error: text сохранён');
|
|
||||||
eq(e3.parseInfo.error, 'битый PDF', 'error: parseInfo сохранён');
|
|
||||||
|
|
||||||
// null parsed (неизвестная ошибка)
|
|
||||||
var e4 = {};
|
|
||||||
applyParseResult(e4, null);
|
|
||||||
eq(e4.status.kind, 'error', 'null: kind=error');
|
|
||||||
eq(e4.status.text, 'Неизвестная ошибка', 'null: дефолтный текст');
|
|
||||||
|
|
||||||
// parsed с пустыми полями
|
|
||||||
var e5 = {};
|
|
||||||
applyParseResult(e5, { status: 'parsed' });
|
|
||||||
eq(e5.status.kind, 'parsed', 'parsed без element_count: kind=parsed');
|
|
||||||
eq(e5.status.count, undefined, 'parsed без element_count: count=undefined');
|
|
||||||
// parsed с element_count=0
|
|
||||||
var e6 = {};
|
|
||||||
applyParseResult(e6, { status: 'parsed', element_count: 0 });
|
|
||||||
eq(e6.status.count, 0, 'parsed element_count=0');
|
|
||||||
// error без текста ошибки
|
|
||||||
var e7 = {};
|
|
||||||
applyParseResult(e7, { status: 'error' });
|
|
||||||
eq(e7.status.text, 'ошибка парсинга', 'error без текста: дефолт "ошибка парсинга"');
|
|
||||||
// entry с уже существующими полями (не должны мешать)
|
|
||||||
var e8 = { existing: true, parsed: false };
|
|
||||||
applyParseResult(e8, { status: 'parsed', element_count: 7 }, '3.0');
|
|
||||||
assert(e8.existing === true, 'старое поле не затёрто');
|
|
||||||
eq(e8.status.count, 7, 'новые данные поверх старых');
|
|
||||||
|
|
||||||
// ── Тесты: reconcileSelection ────────────────────────────────
|
|
||||||
console.log('\n=== reconcileSelection ===');
|
|
||||||
|
|
||||||
var files = [
|
|
||||||
{ name: 'a.docx' }, { name: 'b.pdf' }, { name: 'c.docx' }
|
|
||||||
];
|
|
||||||
var newFiles = [
|
|
||||||
{ name: 'a.docx' }, { name: 'c.docx' }, { name: 'd.pdf' }
|
|
||||||
];
|
|
||||||
var result = reconcileSelection(files, newFiles);
|
|
||||||
eq(result.length, 2, 'должно остаться 2 файла');
|
|
||||||
eq(result[0].name, 'a.docx', 'a.docx остался');
|
|
||||||
eq(result[1].name, 'c.docx', 'c.docx остался');
|
|
||||||
|
|
||||||
// Исходный массив не мутирован
|
|
||||||
eq(files.length, 3, 'исходный массив не мутирован');
|
|
||||||
|
|
||||||
// Пустые входы
|
|
||||||
var emptyResult = reconcileSelection([], []);
|
|
||||||
eq(emptyResult.length, 0, 'пустые массивы → пусто');
|
|
||||||
|
|
||||||
// Все совпадают
|
|
||||||
var allMatch = reconcileSelection([{name:'a'}], [{name:'a'}]);
|
|
||||||
eq(allMatch.length, 1, 'все совпадают → 1');
|
|
||||||
|
|
||||||
// Ни один не совпадает
|
|
||||||
var noMatch = reconcileSelection([{name:'a'},{name:'b'}], [{name:'c'},{name:'d'}]);
|
|
||||||
eq(noMatch.length, 0, 'нет совпадений → пусто');
|
|
||||||
|
|
||||||
// Дубликаты имён в newFiles (должен работать — Set)
|
|
||||||
var dupNames = [{name:'a'}, {name:'a'}, {name:'b'}];
|
|
||||||
var dupResult = reconcileSelection([{name:'a'},{name:'b'}], dupNames);
|
|
||||||
eq(dupResult.length, 2, 'дубликаты в newFiles — Set обрабатывает');
|
|
||||||
|
|
||||||
// ── Тесты: renderGroupCard ───────────────────────────────────
|
|
||||||
console.log('\n=== renderGroupCard ===');
|
|
||||||
|
|
||||||
var group = {
|
|
||||||
contract_number: '123',
|
|
||||||
counterparty: 'ООО Тест',
|
|
||||||
documents: [
|
|
||||||
{ doc_type: 'contract', filename: 'договор.docx', doc_date: '2024-01-15' },
|
|
||||||
{ doc_type: 'supplement', filename: 'дс1.docx' }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
var html = renderGroupCard(group, 0);
|
|
||||||
contains(html, 'Договор №123', 'номер договора');
|
|
||||||
contains(html, 'ООО Тест', 'контрагент');
|
|
||||||
contains(html, 'договор', 'тип contract → договор');
|
|
||||||
contains(html, 'допсоглашение', 'тип supplement → допсоглашение');
|
|
||||||
contains(html, '2024-01-15', 'дата');
|
|
||||||
contains(html, 'data-gi="0"', 'data-gi атрибут');
|
|
||||||
contains(html, 'Сравнить эту группу', 'кнопка сравнения');
|
|
||||||
// НЕ должно быть ✓ Готово
|
|
||||||
assert(html.indexOf('✓ Готово') === -1, 'нет "✓ Готово" для необработанной');
|
|
||||||
|
|
||||||
// Группа с compare.running
|
|
||||||
var groupRunning = {
|
|
||||||
contract_number: '456',
|
|
||||||
counterparty: 'ЗАО Бег',
|
|
||||||
documents: [{ doc_type: 'contract', filename: 'дог.docx' }],
|
|
||||||
compare: { status: 'running' }
|
|
||||||
};
|
|
||||||
var htmlRunning = renderGroupCard(groupRunning, 1);
|
|
||||||
contains(htmlRunning, 'disabled', 'кнопка disabled при running');
|
|
||||||
|
|
||||||
// Без контрагента
|
|
||||||
var groupNoCP = { contract_number: '001', documents: [] };
|
|
||||||
var htmlNoCP = renderGroupCard(groupNoCP, 5);
|
|
||||||
contains(htmlNoCP, 'контрагент не определён', 'без counterparty: заглушка');
|
|
||||||
|
|
||||||
// Неизвестный doc_type
|
|
||||||
var groupUnknown = { contract_number: 'X', counterparty: 'Y', documents: [{ doc_type: 'unknown_type', filename: 'f.docx' }] };
|
|
||||||
var htmlUnknown = renderGroupCard(groupUnknown, 6);
|
|
||||||
contains(htmlUnknown, 'unknown_type', 'неизвестный тип: выводится как есть');
|
|
||||||
|
|
||||||
// Пустой список документов
|
|
||||||
var groupEmpty = { contract_number: 'E', counterparty: 'Empty', documents: [] };
|
|
||||||
var htmlEmpty = renderGroupCard(groupEmpty, 7);
|
|
||||||
contains(htmlEmpty, 'Договор №E', 'пустые документы: карточка рендерится');
|
|
||||||
contains(htmlEmpty, 'Сравнить', 'пустые документы: кнопка есть');
|
|
||||||
|
|
||||||
// ── Тесты: renderGroupCardDone ───────────────────────────────
|
|
||||||
console.log('\n=== renderGroupCardDone ===');
|
|
||||||
|
|
||||||
var groupDone = {
|
|
||||||
contract_number: '789',
|
|
||||||
counterparty: 'ИП Готово',
|
|
||||||
documents: [
|
|
||||||
{ doc_type: 'contract', filename: 'base.docx' },
|
|
||||||
{ doc_type: 'specification', filename: 'spec.docx' }
|
|
||||||
],
|
|
||||||
compare: {
|
|
||||||
status: 'done',
|
|
||||||
totalTime: '12.5с',
|
|
||||||
bodyHTML: '<div>результаты сравнения</div>'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var htmlDone = renderGroupCardDone(groupDone, 2);
|
|
||||||
contains(htmlDone, '✓ Готово (12.5с)', '✓ Готово с временем');
|
|
||||||
contains(htmlDone, 'cmpArrow_2', 'стрелка сворачивания');
|
|
||||||
contains(htmlDone, 'спецификация', 'тип specification → спецификация');
|
|
||||||
contains(htmlDone, 'результаты сравнения', 'bodyHTML вставлен');
|
|
||||||
contains(htmlDone, 'data-gi="2"', 'data-gi на заголовке');
|
|
||||||
|
|
||||||
// ── Тесты: renderUnresolvedCard ──────────────────────────────
|
|
||||||
console.log('\n=== renderUnresolvedCard ===');
|
|
||||||
|
|
||||||
var unresolved = {
|
|
||||||
contract_number: '__unresolved__',
|
|
||||||
documents: [
|
|
||||||
{ doc_type: 'other', filename: 'unknown.docx', parent_number: '999' },
|
|
||||||
{ doc_type: 'contract', filename: 'bad.docx', classify_status: 'failed', error_message: 'LLM error' }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
var htmlUnres = renderUnresolvedCard(unresolved);
|
|
||||||
contains(htmlUnres, 'Не распознано', 'заголовок нераспознанных');
|
|
||||||
contains(htmlUnres, 'нет базового договора №999', 'причина: нет базового');
|
|
||||||
contains(htmlUnres, 'ошибка классификации: LLM error', 'причина: ошибка классификации');
|
|
||||||
|
|
||||||
// ── Тесты: applyCompareEvent ─────────────────────────────────
|
|
||||||
console.log('\n=== applyCompareEvent ===');
|
|
||||||
|
|
||||||
var sections = {};
|
|
||||||
|
|
||||||
// extract_start
|
|
||||||
applyCompareEvent(sections, { type: 'extract_start', supplement_id: 's1', filename: 'test.docx' });
|
|
||||||
assert(sections['s1'] !== undefined, 'секция создана');
|
|
||||||
eq(sections['s1'].filename, 'test.docx', 'filename сохранён');
|
|
||||||
eq(sections['s1'].status, 'extracting', 'status=extracting');
|
|
||||||
|
|
||||||
// llm_done
|
|
||||||
applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's1', ops_count: 15, mode: 'llm', time_s: 3.2 });
|
|
||||||
eq(sections['s1'].status, 'llm_done', 'status=llm_done');
|
|
||||||
eq(sections['s1'].ops_count, 15, 'ops_count=15');
|
|
||||||
eq(sections['s1'].mode, 'llm', 'mode=llm');
|
|
||||||
eq(sections['s1'].time_s, 3.2, 'time_s=3.2');
|
|
||||||
|
|
||||||
// applied
|
|
||||||
applyCompareEvent(sections, {
|
|
||||||
type: 'applied', supplement_id: 's1',
|
|
||||||
summary: { added: 5, updated: 2, deleted: 1 },
|
|
||||||
ops: [{ action: 'ADD', new_row: { name: 'Услуга 1' } }]
|
|
||||||
});
|
|
||||||
eq(sections['s1'].status, 'applied', 'status=applied');
|
|
||||||
eq(sections['s1'].summary.added, 5, 'summary.added=5');
|
|
||||||
eq(sections['s1'].ops.length, 1, 'ops.length=1');
|
|
||||||
|
|
||||||
// extract_error на существующей секции
|
|
||||||
applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's1', error: 'parse failed' });
|
|
||||||
eq(sections['s1'].status, 'error', 'после ошибки: status=error');
|
|
||||||
eq(sections['s1'].error, 'parse failed', 'текст ошибки');
|
|
||||||
|
|
||||||
// extract_error на НЕсуществующей секции
|
|
||||||
applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's2', filename: 'bad.docx', error: 'boom' });
|
|
||||||
eq(sections['s2'].status, 'error', 'новая секция с ошибкой');
|
|
||||||
eq(sections['s2'].filename, 'bad.docx', 'filename для ошибочной секции');
|
|
||||||
|
|
||||||
// Неизвестный тип события — не должен падать
|
|
||||||
applyCompareEvent(sections, { type: 'unknown_type', supplement_id: 's3' });
|
|
||||||
assert(sections['s3'] === undefined, 'неизвестный тип: секция НЕ создана');
|
|
||||||
|
|
||||||
// llm_done для несуществующей секции — не должен падать
|
|
||||||
applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's99', ops_count: 1 });
|
|
||||||
|
|
||||||
// apply_error (должен работать как extract_error)
|
|
||||||
applyCompareEvent(sections, { type: 'apply_error', supplement_id: 's4', filename: 'apply_err.docx', error: 'apply boom' });
|
|
||||||
eq(sections['s4'].status, 'error', 'apply_error: секция с ошибкой создана');
|
|
||||||
eq(sections['s4'].error, 'apply boom', 'apply_error: текст ошибки');
|
|
||||||
|
|
||||||
// applied без summary
|
|
||||||
applyCompareEvent(sections, { type: 'applied', supplement_id: 's1', ops: [] });
|
|
||||||
eq(sections['s1'].status, 'applied', 'applied без summary: status ок');
|
|
||||||
eq(sections['s1'].ops.length, 0, 'applied с пустыми ops');
|
|
||||||
|
|
||||||
// ── Тесты: renderCompareSectionHeader ────────────────────────
|
|
||||||
console.log('\n=== renderCompareSectionHeader ===');
|
|
||||||
|
|
||||||
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'extracting' }), '⏳', 'extracting: ⏳');
|
|
||||||
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '✓', 'llm_done: ✓');
|
|
||||||
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '5 оп.', 'llm_done: ops_count');
|
|
||||||
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), '✗', 'error: ✗');
|
|
||||||
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), 'fail', 'error: текст');
|
|
||||||
|
|
||||||
// ── Тесты: renderCompareOpsTable ─────────────────────────────
|
|
||||||
console.log('\n=== renderCompareOpsTable ===');
|
|
||||||
|
|
||||||
var ops = [
|
|
||||||
{ action: 'ADD', new_row: { name: 'Стойка', price: 100, qty: 2, sum: 200, date_start: '2024-01-01' } },
|
|
||||||
{ action: 'DELETE', new_row: { name: 'IP', price: 50, qty: 1, sum: 50, date_start: '' } }
|
|
||||||
];
|
|
||||||
var tableHtml = renderCompareOpsTable(ops);
|
|
||||||
contains(tableHtml, 'diff-added', 'ADD → diff-added');
|
|
||||||
contains(tableHtml, 'diff-deleted', 'DELETE → diff-deleted');
|
|
||||||
contains(tableHtml, 'Стойка', 'имя услуги');
|
|
||||||
contains(tableHtml, '100', 'цена');
|
|
||||||
contains(tableHtml, '2024-01-01', 'дата');
|
|
||||||
|
|
||||||
// Пустые ops
|
|
||||||
var emptyTable = renderCompareOpsTable([]);
|
|
||||||
contains(emptyTable, '<table', 'пустые ops: таблица рендерится');
|
|
||||||
contains(emptyTable, '<tbody>', 'пустые ops: tbody есть');
|
|
||||||
|
|
||||||
// ops с null new_row
|
|
||||||
var nullRow = renderCompareOpsTable([{ action: 'ADD' }]);
|
|
||||||
contains(nullRow, '<td></td>', 'null new_row: пустые ячейки (не падает)');
|
|
||||||
|
|
||||||
// ops с неизвестным action (без класса)
|
|
||||||
var unknownAct = renderCompareOpsTable([{ action: 'MERGE', new_row: {} }]);
|
|
||||||
assert(unknownAct.indexOf('diff-added') === -1, 'MERGE: нет diff-added');
|
|
||||||
assert(unknownAct.indexOf('diff-deleted') === -1, 'MERGE: нет diff-deleted');
|
|
||||||
assert(unknownAct.indexOf('diff-changed') === -1, 'MERGE: нет diff-changed');
|
|
||||||
|
|
||||||
// ── Тесты: renderCompareSectionBody ──────────────────────────
|
|
||||||
console.log('\n=== renderCompareSectionBody ===');
|
|
||||||
|
|
||||||
eq(renderCompareSectionBody(null), '', 'null → пусто');
|
|
||||||
eq(renderCompareSectionBody({}), '', 'пустой → пусто');
|
|
||||||
eq(renderCompareSectionBody({ status: 'llm_done' }), '', 'llm_done → пусто');
|
|
||||||
|
|
||||||
var secApplied = {
|
|
||||||
status: 'applied',
|
|
||||||
summary: { added: 3, updated: 1, deleted: 0, unresolved: 2 },
|
|
||||||
ops: [{ action: 'UPDATE', new_row: { name: 'Стойка 42U' } }]
|
|
||||||
};
|
|
||||||
var bodyHtml = renderCompareSectionBody(secApplied);
|
|
||||||
contains(bodyHtml, '+3', 'added=3');
|
|
||||||
contains(bodyHtml, '~1', 'updated=1');
|
|
||||||
contains(bodyHtml, '-0', 'deleted=0');
|
|
||||||
contains(bodyHtml, '?2', 'unresolved=2');
|
|
||||||
contains(bodyHtml, 'diff-changed', 'UPDATE → diff-changed');
|
|
||||||
|
|
||||||
// Без unresolved
|
|
||||||
var secNoUnresolved = { status: 'applied', summary: { added: 1, updated: 0, deleted: 0 }, ops: [] };
|
|
||||||
var bodyNoUnr = renderCompareSectionBody(secNoUnresolved);
|
|
||||||
assert(bodyNoUnr.indexOf('?') === -1, 'без unresolved: нет знака ?');
|
|
||||||
|
|
||||||
// Отрицательные значения summary (не должно быть, но не падать)
|
|
||||||
var secNeg = { status: 'applied', summary: { added: -1, updated: 0, deleted: 0 }, ops: [{ action: 'ADD', new_row: {} }] };
|
|
||||||
var bodyNeg = renderCompareSectionBody(secNeg);
|
|
||||||
contains(bodyNeg, '+-1', 'отрицательные summary: рендерится без ошибок');
|
|
||||||
|
|
||||||
// ops с частичным new_row (не все поля)
|
|
||||||
var secPartial = { status: 'applied', summary: { added: 1 }, ops: [
|
|
||||||
{ action: 'ADD', new_row: { name: 'Только имя' } }
|
|
||||||
]};
|
|
||||||
var bodyPartial = renderCompareSectionBody(secPartial);
|
|
||||||
contains(bodyPartial, 'Только имя', 'частичный new_row: имя есть');
|
|
||||||
// Проверим что нет undefined в выводе
|
|
||||||
assert(bodyPartial.indexOf('undefined') === -1, 'нет undefined в выводе');
|
|
||||||
|
|
||||||
// ── Тесты: escHtml ───────────────────────────────────────────
|
|
||||||
console.log('\n=== escHtml ===');
|
|
||||||
|
|
||||||
eq(escHtml(null), '', 'escHtml null → пусто');
|
|
||||||
eq(escHtml(undefined), '', 'escHtml undefined → пусто');
|
|
||||||
eq(escHtml('hello'), 'hello', 'escHtml обычный текст');
|
|
||||||
eq(escHtml('<script>'), '<script>', 'escHtml экранирует < >');
|
|
||||||
eq(escHtml('a&b'), 'a&b', 'escHtml экранирует &');
|
|
||||||
eq(escHtml('"quote"'), '"quote"', 'escHtml экранирует кавычки');
|
|
||||||
eq(escHtml(123), '123', 'escHtml число → строка');
|
|
||||||
|
|
||||||
// ── Дымные тесты API (бэкенд) ────────────────────────────────
|
|
||||||
console.log('\n=== API smoke tests ===');
|
|
||||||
|
|
||||||
var http = require('http');
|
|
||||||
var https = require('https');
|
|
||||||
|
|
||||||
function apiTest(method, url, cb) {
|
|
||||||
var mod = url.startsWith('https') ? https : http;
|
|
||||||
var req = mod.request(url, { method: method, timeout: 5000, rejectUnauthorized: false }, function(res) {
|
|
||||||
var body = '';
|
|
||||||
res.on('data', function(c) { body += c; });
|
|
||||||
res.on('end', function() { cb(null, res.statusCode, body); });
|
|
||||||
});
|
|
||||||
req.on('error', function(e) { cb(e.message); });
|
|
||||||
req.on('timeout', function() { req.destroy(); cb('timeout'); });
|
|
||||||
req.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Эти тесты асинхронные — собираем результаты
|
|
||||||
var apiTests = [];
|
|
||||||
function addApiTest(name, method, url, check) {
|
|
||||||
apiTests.push({ name: name, method: method, url: url, check: check });
|
|
||||||
}
|
|
||||||
|
|
||||||
addApiTest('GET / (главная)', 'GET', 'https://contracts.kube5s.ru/', function(code, body) {
|
|
||||||
assert(code === 200, 'главная: HTTP 200 — got ' + code);
|
|
||||||
contains(body, '<!DOCTYPE html>', 'главная: HTML');
|
|
||||||
});
|
|
||||||
|
|
||||||
addApiTest('POST /api/cleanup', 'POST', 'https://contracts.kube5s.ru/api/cleanup', function(code, body) {
|
|
||||||
// cleanup может вернуть 200 или 500 (если БД недоступна) — оба норм для дымного теста
|
|
||||||
assert(code >= 200 && code < 600, 'cleanup: ответ получен — ' + code);
|
|
||||||
});
|
|
||||||
|
|
||||||
addApiTest('GET /static/state.js', 'GET', 'https://contracts.kube5s.ru/static/state.js', function(code, body) {
|
|
||||||
eq(code, 200, 'state.js: HTTP 200 — got ' + code);
|
|
||||||
contains(body, 'Центральное состояние', 'state.js: содержит описание');
|
|
||||||
});
|
|
||||||
|
|
||||||
addApiTest('GET /static/files.js', 'GET', 'https://contracts.kube5s.ru/static/files.js', function(code) {
|
|
||||||
eq(code, 200, 'files.js: HTTP 200 — got ' + code);
|
|
||||||
});
|
|
||||||
|
|
||||||
addApiTest('GET /static/groups.js', 'GET', 'https://contracts.kube5s.ru/static/groups.js', function(code) {
|
|
||||||
eq(code, 200, 'groups.js: HTTP 200 — got ' + code);
|
|
||||||
});
|
|
||||||
|
|
||||||
addApiTest('GET /static/compare.js', 'GET', 'https://contracts.kube5s.ru/static/compare.js', function(code) {
|
|
||||||
eq(code, 200, 'compare.js: HTTP 200 — got ' + code);
|
|
||||||
});
|
|
||||||
|
|
||||||
addApiTest('GET /static/app.js', 'GET', 'https://contracts.kube5s.ru/static/app.js', function(code) {
|
|
||||||
eq(code, 200, 'app.js: HTTP 200 — got ' + code);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Запускаем API тесты последовательно (чтобы не зафлудить)
|
|
||||||
var apiIdx = 0;
|
|
||||||
function runNextApiTest() {
|
|
||||||
if (apiIdx >= apiTests.length) {
|
|
||||||
// Все API тесты готовы — выводим итоги
|
|
||||||
printResults();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var t = apiTests[apiIdx];
|
|
||||||
apiTest(t.method, t.url, function(err, code, body) {
|
|
||||||
if (err) {
|
|
||||||
console.log(' FAIL: ' + t.name + ' — ' + err);
|
|
||||||
failed++;
|
|
||||||
} else {
|
|
||||||
t.check(code, body || '');
|
|
||||||
}
|
|
||||||
apiIdx++;
|
|
||||||
runNextApiTest();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function printResults() {
|
|
||||||
console.log('\n========================================');
|
|
||||||
console.log('PASS: ' + passed);
|
|
||||||
console.log('FAIL: ' + failed);
|
|
||||||
console.log('========================================');
|
|
||||||
if (failed > 0) process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Начинаем с API тестов после синхронных
|
|
||||||
console.log(' (асинхронные — ждём...)');
|
|
||||||
runNextApiTest();
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
"""
|
|
||||||
test_testgen_pipeline.py — Сквозной тест: testgen → parse → textify → LLM-промпт.
|
|
||||||
|
|
||||||
Пайплайн (без БД, без HTTP):
|
|
||||||
1. testgen генерирует DOCX (договор, спека, допник)
|
|
||||||
2. parse.py парсит байты → elements_json
|
|
||||||
3. process.py::_elements_to_text() → plain text для LLM
|
|
||||||
4. llm_prompt.py строит промпт → проверяем структуру
|
|
||||||
|
|
||||||
Точка входа: elements_json — именно туда приходят данные после парсинга.
|
|
||||||
"""
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
# Добавить пути для импортов
|
|
||||||
_deploy = os.path.join(os.path.dirname(__file__), "..", "..")
|
|
||||||
_testgen = os.path.join(_deploy, "..", "..", "testgen")
|
|
||||||
sys.path.insert(0, _deploy)
|
|
||||||
sys.path.insert(0, _testgen)
|
|
||||||
|
|
||||||
from compare.parse import parse_file
|
|
||||||
from compare.process import _elements_to_text
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
# Helpers
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def _build_and_parse(build_fn, ctx):
|
|
||||||
"""Построить docx через testgen, отдать байты парсеру. Возвращает parsed dict."""
|
|
||||||
from docx import Document
|
|
||||||
doc = build_fn(ctx)
|
|
||||||
buf = io.BytesIO()
|
|
||||||
doc.save(buf)
|
|
||||||
return parse_file("test.docx", buf.getvalue())
|
|
||||||
|
|
||||||
|
|
||||||
def _elements_to_dicts(elements):
|
|
||||||
"""Привести elements к dict для сравнения (убрать лишнее)."""
|
|
||||||
result = []
|
|
||||||
for el in elements:
|
|
||||||
d = {"type": el["type"]}
|
|
||||||
if el["type"] == "paragraph":
|
|
||||||
d["text"] = el.get("text", "")
|
|
||||||
elif el["type"] == "table":
|
|
||||||
d["row_count"] = len(el.get("rows", []))
|
|
||||||
d["col_count"] = len(el["rows"][0]) if el.get("rows") else 0
|
|
||||||
d["first_cell"] = el["rows"][0][0] if el.get("rows") and el["rows"][0] else ""
|
|
||||||
result.append(d)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
# Tests
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
class TestGenerateParseTextify:
|
|
||||||
"""Полный пайплайн: testgen → parse → textify."""
|
|
||||||
|
|
||||||
# ── Данные ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def pools(self):
|
|
||||||
import pools
|
|
||||||
return pools
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def templates(self):
|
|
||||||
import templates
|
|
||||||
return templates
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def contract_ctx(self, pools):
|
|
||||||
ci = 0
|
|
||||||
c = pools.COMPANIES[ci]
|
|
||||||
num = pools.contract_number(ci)
|
|
||||||
date = pools.random_date()
|
|
||||||
return {
|
|
||||||
"company": c, "number": num, "date": date, "company_idx": ci,
|
|
||||||
"comment": "Тестовый контракт для пайплайна.",
|
|
||||||
}
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def spec_ctx(self, pools):
|
|
||||||
ci = 0
|
|
||||||
c = pools.COMPANIES[ci]
|
|
||||||
num = pools.contract_number(ci)
|
|
||||||
date = pools.random_date()
|
|
||||||
svcs, total, total_str = pools.pick_services(5)
|
|
||||||
for s in svcs:
|
|
||||||
s["date_start"] = pools.random_date(2026, 3, 3)
|
|
||||||
return {
|
|
||||||
"company": c, "number": num, "date": date, "contract_date": date,
|
|
||||||
"version": 1, "services": svcs, "total": total, "total_str": total_str,
|
|
||||||
"label": f"Абонентские услуги с {date}",
|
|
||||||
"comment": "Тестовая спецификация для пайплайна.",
|
|
||||||
}
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def addendum_ctx(self, pools):
|
|
||||||
ci = 0
|
|
||||||
c = pools.COMPANIES[ci]
|
|
||||||
num = pools.contract_number(ci)
|
|
||||||
date = pools.random_date()
|
|
||||||
inst, itot, itot_s = pools.pick_services(2)
|
|
||||||
mon, mtot, mtot_s = pools.pick_services(3)
|
|
||||||
return {
|
|
||||||
"company": c, "number": num, "date": date, "contract_date": pools.random_date(),
|
|
||||||
"addendum_num": 1,
|
|
||||||
"services_install": inst, "install_total": itot, "install_total_str": itot_s,
|
|
||||||
"services_monthly": mon, "monthly_total": mtot, "monthly_total_str": mtot_s,
|
|
||||||
"comment": "Тестовый допник для пайплайна.",
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Этап 1: Генерация → парсинг ───────────────────────────
|
|
||||||
|
|
||||||
def test_generate_contract_parse_ok(self, templates, contract_ctx):
|
|
||||||
"""Договор: генерируется и парсится без ошибок."""
|
|
||||||
result = _build_and_parse(templates.build_contract, contract_ctx)
|
|
||||||
assert result["status"] == "parsed", f"Parse failed: {result.get('error')}"
|
|
||||||
assert result["element_count"] > 0
|
|
||||||
|
|
||||||
def test_generate_contract_has_paragraphs(self, templates, contract_ctx):
|
|
||||||
"""Договор: в elements есть paragraphs."""
|
|
||||||
result = _build_and_parse(templates.build_contract, contract_ctx)
|
|
||||||
paragraphs = [e for e in result["elements"] if e["type"] == "paragraph"]
|
|
||||||
assert len(paragraphs) >= 3, f"Expected >=3 paragraphs, got {len(paragraphs)}"
|
|
||||||
|
|
||||||
def test_generate_contract_has_tables(self, templates, contract_ctx):
|
|
||||||
"""Договор: в elements есть таблица реквизитов."""
|
|
||||||
result = _build_and_parse(templates.build_contract, contract_ctx)
|
|
||||||
tables = [e for e in result["elements"] if e["type"] == "table"]
|
|
||||||
assert len(tables) >= 1, f"Expected >=1 table, got {len(tables)}"
|
|
||||||
|
|
||||||
def test_generate_spec_parse_ok(self, templates, spec_ctx):
|
|
||||||
"""Спецификация: генерируется и парсится без ошибок."""
|
|
||||||
result = _build_and_parse(templates.build_spec, spec_ctx)
|
|
||||||
assert result["status"] == "parsed"
|
|
||||||
assert result["element_count"] > 0
|
|
||||||
|
|
||||||
def test_generate_spec_has_services_table(self, templates, spec_ctx):
|
|
||||||
"""Спецификация: таблица услуг найдена."""
|
|
||||||
result = _build_and_parse(templates.build_spec, spec_ctx)
|
|
||||||
tables = [e for e in result["elements"] if e["type"] == "table"]
|
|
||||||
assert len(tables) >= 1
|
|
||||||
# Первая таблица — услуги, должна быть НЕ пустой
|
|
||||||
svc_table = tables[0]
|
|
||||||
assert len(svc_table["rows"]) >= 2 # заголовок + минимум 1 строка
|
|
||||||
|
|
||||||
def test_generate_addendum_parse_ok(self, templates, addendum_ctx):
|
|
||||||
"""Допсоглашение: генерируется и парсится без ошибок."""
|
|
||||||
result = _build_and_parse(templates.build_addendum, addendum_ctx)
|
|
||||||
assert result["status"] == "parsed"
|
|
||||||
assert result["element_count"] > 0
|
|
||||||
|
|
||||||
def test_generate_addendum_has_tables(self, templates, addendum_ctx):
|
|
||||||
"""Допсоглашение: таблицы разовых и абонентских услуг."""
|
|
||||||
result = _build_and_parse(templates.build_addendum, addendum_ctx)
|
|
||||||
tables = [e for e in result["elements"] if e["type"] == "table"]
|
|
||||||
# Должно быть минимум 3 таблицы: реквизиты + разовые + абонентские
|
|
||||||
assert len(tables) >= 3, f"Expected >=3 tables, got {len(tables)}"
|
|
||||||
|
|
||||||
# ── Этап 2: elements_json → textify ────────────────────────
|
|
||||||
|
|
||||||
def test_textify_contract(self, templates, contract_ctx):
|
|
||||||
"""Договор after textify содержит ключевые слова."""
|
|
||||||
result = _build_and_parse(templates.build_contract, contract_ctx)
|
|
||||||
text = _elements_to_text(result["elements"])
|
|
||||||
assert "Договор" in text or "ДОГОВОР" in text
|
|
||||||
assert "НУБЕС" in text
|
|
||||||
assert len(text) > 200, f"Text too short: {len(text)} chars"
|
|
||||||
|
|
||||||
def test_textify_spec_contains_table_markers(self, templates, spec_ctx):
|
|
||||||
"""Спецификация after textify: пайп-таблицы на месте."""
|
|
||||||
result = _build_and_parse(templates.build_spec, spec_ctx)
|
|
||||||
text = _elements_to_text(result["elements"])
|
|
||||||
# Должны быть маркеры таблиц (--- Таблица ... ---)
|
|
||||||
assert "Таблица" in text
|
|
||||||
# Должны быть пайпы (формат ячеек)
|
|
||||||
assert "|" in text
|
|
||||||
# Должны быть названия услуг
|
|
||||||
for s in spec_ctx["services"]:
|
|
||||||
# Проверяем первые 30 символов названия
|
|
||||||
short_name = s["name"][:30]
|
|
||||||
assert short_name in text, f"Service '{short_name}' not found in textified output"
|
|
||||||
|
|
||||||
def test_textify_spec_contains_total(self, templates, spec_ctx):
|
|
||||||
"""Спецификация: итоговая сумма в тексте."""
|
|
||||||
result = _build_and_parse(templates.build_spec, spec_ctx)
|
|
||||||
text = _elements_to_text(result["elements"])
|
|
||||||
assert "Итого" in text
|
|
||||||
|
|
||||||
def test_textify_addendum_structure(self, templates, addendum_ctx):
|
|
||||||
"""Допсоглашение after textify: структура сохраняется."""
|
|
||||||
result = _build_and_parse(templates.build_addendum, addendum_ctx)
|
|
||||||
text = _elements_to_text(result["elements"])
|
|
||||||
assert "Соглашение" in text or "СОГЛАШЕНИЕ" in text
|
|
||||||
assert "Инсталляц" in text or "Разовые" in text
|
|
||||||
assert "Абонентские" in text
|
|
||||||
|
|
||||||
# ── Этап 3: elements_json → промпт LLM ─────────────────────
|
|
||||||
|
|
||||||
def test_build_extract_prompt_from_elements(self, templates, spec_ctx):
|
|
||||||
"""Из elements_json спецификации строится extract-промпт.
|
|
||||||
Пустая current_spec → extract mode (FALLBACK_EXTRACT)."""
|
|
||||||
from llm_prompt import build_prompt
|
|
||||||
result = _build_and_parse(templates.build_spec, spec_ctx)
|
|
||||||
doc_text = _elements_to_text(result["elements"])
|
|
||||||
|
|
||||||
# Пустая current_spec = первый документ = extract-промпт
|
|
||||||
prompt_text, prompt_id = build_prompt([], doc_text)
|
|
||||||
assert len(prompt_text) > 100
|
|
||||||
assert "{doc_text}" not in prompt_text # переменная подставлена
|
|
||||||
assert doc_text in prompt_text # текст документа внутри промпта
|
|
||||||
assert "JSON" in prompt_text or "json" in prompt_text
|
|
||||||
|
|
||||||
def test_build_diff_prompt_from_elements(self, templates, addendum_ctx):
|
|
||||||
"""Из elements_json допсоглашения строится diff-промпт.
|
|
||||||
Непустая current_spec → diff mode (FALLBACK_DIFF)."""
|
|
||||||
from llm_prompt import build_prompt
|
|
||||||
result = _build_and_parse(templates.build_addendum, addendum_ctx)
|
|
||||||
doc_text = _elements_to_text(result["elements"])
|
|
||||||
|
|
||||||
# Непустая current_spec = не первый документ = diff-промпт
|
|
||||||
current_spec = [
|
|
||||||
{"hash": "h1", "name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2026-01-01"},
|
|
||||||
]
|
|
||||||
prompt_text, prompt_id = build_prompt(current_spec, doc_text)
|
|
||||||
assert len(prompt_text) > 100
|
|
||||||
assert doc_text in prompt_text
|
|
||||||
assert "UPDATE" in prompt_text or "DELETE" in prompt_text or "ADD" in prompt_text
|
|
||||||
|
|
||||||
# ── Этап 4: edge cases ─────────────────────────────────────
|
|
||||||
|
|
||||||
def test_parse_empty_docx(self):
|
|
||||||
"""Пустой docx — парсится без ошибок, но без элементов."""
|
|
||||||
from docx import Document
|
|
||||||
doc = Document()
|
|
||||||
buf = io.BytesIO()
|
|
||||||
doc.save(buf)
|
|
||||||
result = parse_file("empty.docx", buf.getvalue())
|
|
||||||
assert result["status"] == "parsed"
|
|
||||||
assert result["element_count"] == 0
|
|
||||||
|
|
||||||
def test_parse_docx_text_only(self):
|
|
||||||
"""DOCX только с текстом — корректно парсится."""
|
|
||||||
from docx import Document
|
|
||||||
doc = Document()
|
|
||||||
doc.add_paragraph("Привет мир")
|
|
||||||
doc.add_paragraph("Вторая строка")
|
|
||||||
buf = io.BytesIO()
|
|
||||||
doc.save(buf)
|
|
||||||
result = parse_file("text.docx", buf.getvalue())
|
|
||||||
assert result["status"] == "parsed"
|
|
||||||
assert result["element_count"] == 2
|
|
||||||
assert all(e["type"] == "paragraph" for e in result["elements"])
|
|
||||||
|
|
||||||
def test_parse_docx_table_only(self):
|
|
||||||
"""DOCX только с таблицей — корректно парсится."""
|
|
||||||
from docx import Document
|
|
||||||
doc = Document()
|
|
||||||
table = doc.add_table(rows=2, cols=3)
|
|
||||||
table.rows[0].cells[0].text = "A"
|
|
||||||
table.rows[0].cells[1].text = "B"
|
|
||||||
table.rows[0].cells[2].text = "C"
|
|
||||||
table.rows[1].cells[0].text = "1"
|
|
||||||
table.rows[1].cells[1].text = "2"
|
|
||||||
table.rows[1].cells[2].text = "3"
|
|
||||||
buf = io.BytesIO()
|
|
||||||
doc.save(buf)
|
|
||||||
result = parse_file("table.docx", buf.getvalue())
|
|
||||||
assert result["status"] == "parsed"
|
|
||||||
tables = [e for e in result["elements"] if e["type"] == "table"]
|
|
||||||
assert len(tables) == 1
|
|
||||||
assert tables[0]["rows"] == [["A", "B", "C"], ["1", "2", "3"]]
|
|
||||||
|
|
||||||
def test_textify_preserves_table_data(self):
|
|
||||||
"""Textify сохраняет все данные таблицы (без потерь)."""
|
|
||||||
elements = [
|
|
||||||
{"type": "table", "rows": [
|
|
||||||
["Услуга", "Цена", "Кол-во"],
|
|
||||||
["Аренда стойки", "50000", "2"],
|
|
||||||
["IP-адрес", "300", "8"],
|
|
||||||
]}
|
|
||||||
]
|
|
||||||
text = _elements_to_text(elements)
|
|
||||||
assert "Услуга" in text
|
|
||||||
assert "Аренда стойки" in text
|
|
||||||
assert "50000" in text
|
|
||||||
assert "2" in text
|
|
||||||
assert "IP-адрес" in text
|
|
||||||
assert "300" in text
|
|
||||||
assert "8" in text
|
|
||||||
|
|
||||||
# ── Этап 5: валидация manifest-подобной структуры ──────────
|
|
||||||
|
|
||||||
def test_contract_elements_structure(self, templates, contract_ctx):
|
|
||||||
"""Элементы договора имеют правильную структуру."""
|
|
||||||
result = _build_and_parse(templates.build_contract, contract_ctx)
|
|
||||||
for el in result["elements"]:
|
|
||||||
assert "type" in el
|
|
||||||
assert el["type"] in ("paragraph", "table")
|
|
||||||
if el["type"] == "paragraph":
|
|
||||||
assert "text" in el
|
|
||||||
assert isinstance(el["text"], str)
|
|
||||||
elif el["type"] == "table":
|
|
||||||
assert "rows" in el
|
|
||||||
assert isinstance(el["rows"], list)
|
|
||||||
assert len(el["rows"]) > 0
|
|
||||||
assert all(isinstance(row, list) for row in el["rows"])
|
|
||||||
|
|
||||||
def test_multiple_contracts_different_content(self, templates, pools):
|
|
||||||
"""Разные контракты дают разный elements_json."""
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for ci in range(3):
|
|
||||||
c = pools.COMPANIES[ci]
|
|
||||||
ctx = {
|
|
||||||
"company": c, "number": pools.contract_number(ci),
|
|
||||||
"date": pools.random_date(), "company_idx": ci,
|
|
||||||
"comment": f"Контракт {ci}",
|
|
||||||
}
|
|
||||||
result = _build_and_parse(templates.build_contract, ctx)
|
|
||||||
# Сериализуем для сравнения
|
|
||||||
txt = _elements_to_text(result["elements"])
|
|
||||||
results.append(txt)
|
|
||||||
|
|
||||||
# Все три должны быть разными
|
|
||||||
hashes = [hashlib.md5(t.encode()).hexdigest() for t in results]
|
|
||||||
assert len(set(hashes)) == 3, f"All 3 contracts produced same text!"
|
|
||||||
|
|
||||||
def test_spec_v1_v2_produce_different_tables(self, templates, pools):
|
|
||||||
"""Спецификации v1 и v2 дают разные таблицы (разные цены/объёмы)."""
|
|
||||||
c = pools.COMPANIES[0]
|
|
||||||
base_ctx = {
|
|
||||||
"company": c, "number": pools.contract_number(0),
|
|
||||||
"date": pools.random_date(), "contract_date": pools.random_date(),
|
|
||||||
"version": 1,
|
|
||||||
"label": "Абонентские услуги",
|
|
||||||
}
|
|
||||||
|
|
||||||
# v1
|
|
||||||
svcs_v1, tot_v1, tot_str_v1 = pools.pick_services(5)
|
|
||||||
ctx_v1 = {**base_ctx, "services": svcs_v1, "total": tot_v1, "total_str": tot_str_v1}
|
|
||||||
r1 = _build_and_parse(templates.build_spec, ctx_v1)
|
|
||||||
t1 = _elements_to_text(r1["elements"])
|
|
||||||
|
|
||||||
# v2 — меняем цены
|
|
||||||
import copy
|
|
||||||
svcs_v2 = copy.deepcopy(svcs_v1)
|
|
||||||
if svcs_v2:
|
|
||||||
svcs_v2[0]["price"] = round(svcs_v2[0]["price"] * 1.25, 2)
|
|
||||||
svcs_v2[0]["price_str"] = pools.format_price(svcs_v2[0]["price"])
|
|
||||||
svcs_v2[0]["sum"] = round(svcs_v2[0]["price"] * svcs_v2[0]["qty"], 2)
|
|
||||||
svcs_v2[0]["sum_str"] = pools.format_price(svcs_v2[0]["sum"])
|
|
||||||
tot_v2 = round(sum(s["sum"] for s in svcs_v2), 2)
|
|
||||||
ctx_v2 = {**base_ctx, "version": 2, "services": svcs_v2, "total": tot_v2, "total_str": pools.format_price(tot_v2)}
|
|
||||||
r2 = _build_and_parse(templates.build_spec, ctx_v2)
|
|
||||||
t2 = _elements_to_text(r2["elements"])
|
|
||||||
|
|
||||||
assert t1 != t2, "v1 and v2 specs produced identical text!"
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
"""
|
|
||||||
Тест DrHider — много проверок.
|
|
||||||
|
|
||||||
Файл: /home/naeel/nubes/contracts/contracts-flask/deploy/tests/test_drhider.py
|
|
||||||
"""
|
|
||||||
import sys, os, io, zipfile, json, base64
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
|
||||||
from drhider.drhider import obfuscate_files, TwoPassObfuscator, COMPANY_PATTERN, PERSON_PATTERN
|
|
||||||
|
|
||||||
TEST_ZIP = "/home/naeel/nubes/contracts/dogovora/примеры_договоров_для_ИИ.zip"
|
|
||||||
|
|
||||||
def run(text, desc):
|
|
||||||
"""Обработать текст и проверить что needle НЕ найдено."""
|
|
||||||
z, c = obfuscate_files([('t.txt', text.encode(), '')])
|
|
||||||
with zipfile.ZipFile(io.BytesIO(z)) as zf:
|
|
||||||
out = zf.read('t.txt').decode()
|
|
||||||
csv = zf.read('mapping.csv').decode()
|
|
||||||
return out, csv
|
|
||||||
|
|
||||||
errors = 0
|
|
||||||
|
|
||||||
def check(ok, msg):
|
|
||||||
global errors
|
|
||||||
if ok:
|
|
||||||
print(f" ✅ {msg}")
|
|
||||||
else:
|
|
||||||
print(f" ❌ {msg}")
|
|
||||||
errors += 1
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("DrHider — полный тест")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# ── 1. Телефоны ──
|
|
||||||
print("\n📞 Телефоны:")
|
|
||||||
out, csv = run("Звоните +7 (495) 789-41-35 или 8-800-555-35-35", "телефоны")
|
|
||||||
check("+7 (495) 789-41-35" not in out, "+7 (495) 789-41-35 заменён")
|
|
||||||
check("8-800-555-35-35" not in out, "8-800-555-35-35 заменён")
|
|
||||||
check("phone" in csv, "тип phone в CSV")
|
|
||||||
|
|
||||||
# ── 2. Телефон не матчит число внутри счёта ──
|
|
||||||
print("\n📞 Телефон vs номер счёта:")
|
|
||||||
out, csv = run("Кор/сч 30101810400000000225", "счёт")
|
|
||||||
check("30101810400000000225" not in out, "номер счёта заменён (ks)")
|
|
||||||
check("+7" not in out, "нет ложного телефона внутри счёта")
|
|
||||||
|
|
||||||
# ── 3. Email ──
|
|
||||||
print("\n📧 Email:")
|
|
||||||
out, csv = run("Пишите info@nubes.ru и support@company.org", "email")
|
|
||||||
check("info@nubes.ru" not in out, "info@nubes.ru заменён")
|
|
||||||
check("support@company.org" not in out, "support@company.org заменён")
|
|
||||||
check("email" in csv, "тип email в CSV")
|
|
||||||
|
|
||||||
# ── 4. Компании (разные кавычки) ──
|
|
||||||
print("\n🏢 Компании:")
|
|
||||||
out, csv = run('ООО "Ромашка" и АО \u201cXXX003\u201d и ЗАО «Тест»', "компании")
|
|
||||||
check('ООО "Ромашка"' not in out, 'ООО "Ромашка" заменён')
|
|
||||||
check('АО \u201cXXX003\u201d' not in out, 'АО "XXX003" (unicode) заменён')
|
|
||||||
check('ЗАО «Тест»' not in out, 'ЗАО «Тест» заменён')
|
|
||||||
check('company' in csv, 'тип company в CSV')
|
|
||||||
|
|
||||||
# ── 5. НУБЕС whitelist ──
|
|
||||||
print("\n🛡️ НУБЕС whitelist:")
|
|
||||||
out, csv = run('ООО "НУБЕС" и ООО \u201cНУБЕС\u201d', "НУБЕС")
|
|
||||||
check('ООО "НУБЕС"' in out, 'НУБЕС (ASCII) НЕ заменён')
|
|
||||||
check('ООО \u201cНУБЕС\u201d' in out, 'НУБЕС (unicode) НЕ заменён')
|
|
||||||
|
|
||||||
# ── 6. ИНН/ОГРН/КПП/БИК ──
|
|
||||||
print("\n🔢 ИНН/ОГРН/КПП/БИК:")
|
|
||||||
out, csv = run("ИНН 9706005293, КПП 772401001, ОГРН 1207700098759, БИК 044525225", "реквизиты")
|
|
||||||
check("9706005293" not in out, "ИНН заменён")
|
|
||||||
check("772401001" not in out, "КПП заменён")
|
|
||||||
check("1207700098759" not in out, "ОГРН заменён")
|
|
||||||
check("044525225" not in out, "БИК заменён")
|
|
||||||
check("inn_ul" in csv, "inn_ul в CSV")
|
|
||||||
|
|
||||||
# ── 7. Расчётный счёт ──
|
|
||||||
print("\n💳 Расчётный счёт:")
|
|
||||||
out, csv = run("р/с 40702810738000174030 и Кор/сч 30101810400000000225", "счета")
|
|
||||||
check("40702810738000174030" not in out, "р/с заменён")
|
|
||||||
check("30101810400000000225" not in out, "кор/сч заменён")
|
|
||||||
check("rs" in csv, "rs в CSV")
|
|
||||||
check("ks" in csv, "ks в CSV")
|
|
||||||
|
|
||||||
# ── 8. ФИО (инициалы + полностью) ──
|
|
||||||
print("\n👤 ФИО:")
|
|
||||||
out, csv = run("Директор Степаненко В.С. и Иванов Александр Петрович", "ФИО")
|
|
||||||
check("Степаненко В.С." not in out, "Степаненко В.С. заменён")
|
|
||||||
check("Иванов Александр Петрович" not in out, "Иванов А.П. заменён")
|
|
||||||
|
|
||||||
# ── 9. Паспорт ──
|
|
||||||
print("\n📄 Паспорт:")
|
|
||||||
out, csv = run("паспорт 12 34 567890", "паспорт")
|
|
||||||
check("12 34 567890" not in out, "номер паспорта заменён")
|
|
||||||
|
|
||||||
# ── 10. Согласованность (одна сущность → одна замена) ──
|
|
||||||
print("\n🔄 Согласованность:")
|
|
||||||
z, c = obfuscate_files([
|
|
||||||
('a.txt', b'OOO Romashka +79991234567', ''),
|
|
||||||
('b.txt', b'OOO Romashka +79991234567', ''),
|
|
||||||
])
|
|
||||||
with zipfile.ZipFile(io.BytesIO(z)) as zf:
|
|
||||||
a = zf.read('a.txt').decode()
|
|
||||||
b = zf.read('b.txt').decode()
|
|
||||||
check(a == b, f"оба файла одинаковы: {a}")
|
|
||||||
|
|
||||||
# ── 11. ZIP внутри ZIP ──
|
|
||||||
print("\n📦 ZIP внутри:")
|
|
||||||
with open(TEST_ZIP, 'rb') as f:
|
|
||||||
raw = f.read()
|
|
||||||
z, c = obfuscate_files([('test.zip', raw, '')])
|
|
||||||
with zipfile.ZipFile(io.BytesIO(z)) as zf:
|
|
||||||
names = zf.namelist()
|
|
||||||
check(len(names) == 6, f"6 файлов (5 + csv): {len(names)}")
|
|
||||||
check('mapping.csv' in names, "mapping.csv есть")
|
|
||||||
check(not any(n.endswith('.zip') for n in names), "нет .zip в выдаче")
|
|
||||||
# Проверим что docx внутри валидны
|
|
||||||
for n in names:
|
|
||||||
if n.endswith('.docx'):
|
|
||||||
try:
|
|
||||||
from docx import Document
|
|
||||||
Document(io.BytesIO(zf.read(n)))
|
|
||||||
check(True, f"{n} — валидный docx")
|
|
||||||
except:
|
|
||||||
check(False, f"{n} — БИТЫЙ docx")
|
|
||||||
|
|
||||||
# ── 12. .doc бинарный — не трогаем ──
|
|
||||||
print("\n📄 .doc бинарный:")
|
|
||||||
# Создаём фейковый .doc файл (просто бинарные данные)
|
|
||||||
z, c = obfuscate_files([('old.doc', b'\xD0\xCF\x11\xE0' + b'\x00' * 100, '')])
|
|
||||||
with zipfile.ZipFile(io.BytesIO(z)) as zf:
|
|
||||||
doc_content = zf.read('old.doc')
|
|
||||||
check(len(doc_content) == 104, ".doc сохранён без изменений")
|
|
||||||
|
|
||||||
# ── 13. Много файлов ──
|
|
||||||
print("\n📚 20 файлов:")
|
|
||||||
many = [('f{}.txt'.format(i), 'OOO Test{} +7999{}'.format(i, i).encode(), '') for i in range(20)]
|
|
||||||
z, c = obfuscate_files(many)
|
|
||||||
with zipfile.ZipFile(io.BytesIO(z)) as zf:
|
|
||||||
check(len(zf.namelist()) == 21, "20 файлов + csv")
|
|
||||||
|
|
||||||
# ── 14. COMPANY_PATTERN не жадный ──
|
|
||||||
print("\n🔤 COMPANY_PATTERN границы:")
|
|
||||||
m = COMPANY_PATTERN.search("АО Test с дополнительным текстом дальше")
|
|
||||||
check(m is not None and 'дальше' not in m.group(0), "не захватывает лишний текст")
|
|
||||||
|
|
||||||
# ── 15. PERSON_PATTERN только кириллица ──
|
|
||||||
print("\n🔤 PERSON_PATTERN кириллица:")
|
|
||||||
m = PERSON_PATTERN.search("Next Generation Cloud service")
|
|
||||||
check(m is None, "английский не матчится")
|
|
||||||
|
|
||||||
m2 = PERSON_PATTERN.search("Иванов Иван Иванович")
|
|
||||||
check(m2 is not None, "русский матчится")
|
|
||||||
|
|
||||||
# ── ИТОГО ──
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
if errors:
|
|
||||||
print(f"❌ {errors} ошибок")
|
|
||||||
else:
|
|
||||||
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
sys.exit(0 if errors == 0 else 1)
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""Test classify_batch with MemRepository + FakeLLM."""
|
|
||||||
from compare.classify import classify_batch
|
|
||||||
from compare.llm_client import FakeLLMClient
|
|
||||||
from repository import MemRepository
|
|
||||||
|
|
||||||
|
|
||||||
class TestClassifyBatch:
|
|
||||||
def test_garbage_by_filename(self):
|
|
||||||
"""Счёт-фактура отфильтровывается без LLM."""
|
|
||||||
repo = MemRepository()
|
|
||||||
llm = FakeLLMClient({"default": '{"doc_type":"contract"}'})
|
|
||||||
|
|
||||||
repo.insert_document("счет-фактура №123.docx", "mime", "data", batch_id="b1")
|
|
||||||
|
|
||||||
result = classify_batch("b1", llm_client=llm, repo=repo)
|
|
||||||
assert result["ok"] is True
|
|
||||||
assert result["garbage"] == 1
|
|
||||||
assert result["done"] == 1
|
|
||||||
assert len(llm.calls) == 0 # LLM не вызывался
|
|
||||||
|
|
||||||
def test_garbage_by_header(self):
|
|
||||||
"""Акт сверки в тексте отфильтровывается."""
|
|
||||||
repo = MemRepository()
|
|
||||||
llm = FakeLLMClient({"default": '{"doc_type":"contract"}'})
|
|
||||||
|
|
||||||
doc = repo.insert_document("документ.docx", "mime", "data", batch_id="b1")
|
|
||||||
repo.set_document_parsed(doc["id"], [
|
|
||||||
{"type": "paragraph", "text": "АКТ СВЕРКИ взаимных расчётов", "style": ""}
|
|
||||||
])
|
|
||||||
|
|
||||||
result = classify_batch("b1", llm_client=llm, repo=repo)
|
|
||||||
assert result["garbage"] == 1
|
|
||||||
assert len(llm.calls) == 0
|
|
||||||
|
|
||||||
def test_llm_classify(self):
|
|
||||||
"""Договор классифицируется через LLM."""
|
|
||||||
repo = MemRepository()
|
|
||||||
llm = FakeLLMClient({
|
|
||||||
"default": '{"doc_type":"contract","own_number":"03700_1","doc_date":"2026-02-01","counterparty":"ЗАО XXX001"}'
|
|
||||||
})
|
|
||||||
|
|
||||||
doc = repo.insert_document("договор-XXX001.docx", "mime", "data", batch_id="b1")
|
|
||||||
repo.set_document_parsed(doc["id"], [
|
|
||||||
{"type": "paragraph", "text": "ДОГОВОР № 03700_1", "style": ""}
|
|
||||||
])
|
|
||||||
|
|
||||||
result = classify_batch("b1", llm_client=llm, repo=repo)
|
|
||||||
assert result["done"] == 1, f"done={result['done']}, failed={result['failed']}, doc={repo.get_document(doc['id'])}"
|
|
||||||
assert result["garbage"] == 0
|
|
||||||
assert len(llm.calls) == 1
|
|
||||||
|
|
||||||
updated = repo.get_document(doc["id"])
|
|
||||||
assert updated["doc_type"] == "contract"
|
|
||||||
assert updated["own_number"] == "03700_1"
|
|
||||||
assert updated["counterparty"] == "ЗАО XXX001"
|
|
||||||
|
|
||||||
def test_no_pending(self):
|
|
||||||
"""Пустой батч."""
|
|
||||||
repo = MemRepository()
|
|
||||||
result = classify_batch("empty", llm_client=FakeLLMClient(), repo=repo)
|
|
||||||
assert result["ok"] is False
|
|
||||||
assert "no pending" in result["error"]
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
"""Unit tests — no DB, no network, just pure logic."""
|
|
||||||
import pytest
|
|
||||||
from contracts import ParseResult, ClassifyResult, GroupingResult, CompareOp
|
|
||||||
from compare.upload import parse_multipart
|
|
||||||
from compare.classify import _is_garbage_by_filename, _is_garbage_by_header
|
|
||||||
|
|
||||||
|
|
||||||
class TestContracts:
|
|
||||||
"""Dataclass creation and validation."""
|
|
||||||
|
|
||||||
def test_parse_result(self):
|
|
||||||
r = ParseResult(status="parsed", element_count=10, elements=[])
|
|
||||||
assert r.status == "parsed"
|
|
||||||
assert r.element_count == 10
|
|
||||||
|
|
||||||
def test_classify_from_llm(self):
|
|
||||||
r = ClassifyResult.from_llm({
|
|
||||||
"doc_type": "contract",
|
|
||||||
"own_number": "03700_1",
|
|
||||||
"doc_date": "2026-02-01",
|
|
||||||
"counterparty": "ЗАО XXX001",
|
|
||||||
})
|
|
||||||
assert r.doc_type == "contract"
|
|
||||||
assert r.own_number == "03700_1"
|
|
||||||
assert r.counterparty == "ЗАО XXX001"
|
|
||||||
|
|
||||||
def test_grouping_result_empty(self):
|
|
||||||
r = GroupingResult(contract_number="03700_1")
|
|
||||||
assert r.contract_number == "03700_1"
|
|
||||||
assert r.documents == []
|
|
||||||
|
|
||||||
def test_compare_op_add(self):
|
|
||||||
op = CompareOp(action="ADD", new_row={"name": "Тест", "price": 100})
|
|
||||||
assert op.action == "ADD"
|
|
||||||
assert op.new_row["price"] == 100
|
|
||||||
|
|
||||||
def test_compare_op_from_llm(self):
|
|
||||||
op = CompareOp.from_llm({
|
|
||||||
"action": "UPDATE",
|
|
||||||
"target_id": "r1",
|
|
||||||
"new_values": {"price": 200},
|
|
||||||
"comment": "change",
|
|
||||||
})
|
|
||||||
assert op.action == "UPDATE"
|
|
||||||
assert op.new_values["price"] == 200
|
|
||||||
assert op.comment == "change"
|
|
||||||
|
|
||||||
|
|
||||||
class TestGarbageFilter:
|
|
||||||
"""Stage 1-2 garbage detection."""
|
|
||||||
|
|
||||||
def test_filename_garbage_invoice(self):
|
|
||||||
assert _is_garbage_by_filename("счет-фактура №123.docx") is True
|
|
||||||
|
|
||||||
def test_filename_garbage_act(self):
|
|
||||||
assert _is_garbage_by_filename("Акт сверки за май.pdf") is True
|
|
||||||
|
|
||||||
def test_filename_not_garbage(self):
|
|
||||||
assert _is_garbage_by_filename("договор-XXX001.docx") is False
|
|
||||||
assert _is_garbage_by_filename("спецификация услуг.pdf") is False
|
|
||||||
|
|
||||||
def test_header_garbage(self):
|
|
||||||
text = "СЧЕТ-ФАКТУРА № 123 от 01.01.2026\nПоставщик: ООО Ромашка"
|
|
||||||
assert _is_garbage_by_header(text) is True
|
|
||||||
|
|
||||||
def test_header_not_garbage(self):
|
|
||||||
text = "ДОГОВОР № 03700_1\nг. Москва\n\nИсполнитель обязуется..."
|
|
||||||
assert _is_garbage_by_header(text) is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseMultipart:
|
|
||||||
"""Pure multipart parsing without HTTP."""
|
|
||||||
|
|
||||||
def test_simple_file(self, sample_docx_bytes):
|
|
||||||
boundary = b"----testboundary"
|
|
||||||
body = (
|
|
||||||
b"------testboundary\r\n"
|
|
||||||
b'Content-Disposition: form-data; name="files"; filename="test.docx"\r\n'
|
|
||||||
b"Content-Type: application/octet-stream\r\n\r\n"
|
|
||||||
+ sample_docx_bytes +
|
|
||||||
b"\r\n------testboundary--\r\n"
|
|
||||||
)
|
|
||||||
result = parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
|
|
||||||
assert result["filename"] == "test.docx"
|
|
||||||
assert result["file_data"] == sample_docx_bytes
|
|
||||||
|
|
||||||
def test_with_contract_id(self, sample_docx_bytes):
|
|
||||||
boundary = b"----testboundary"
|
|
||||||
body = (
|
|
||||||
b"------testboundary\r\n"
|
|
||||||
b'Content-Disposition: form-data; name="files"; filename="test.docx"\r\n\r\n'
|
|
||||||
+ sample_docx_bytes +
|
|
||||||
b"\r\n------testboundary\r\n"
|
|
||||||
b'Content-Disposition: form-data; name="contract_id"\r\n\r\n'
|
|
||||||
b"550e8400-e29b-41d4-a716-446655440000"
|
|
||||||
b"\r\n------testboundary--\r\n"
|
|
||||||
)
|
|
||||||
result = parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
|
|
||||||
assert result["contract_id"] == "550e8400-e29b-41d4-a716-446655440000"
|
|
||||||
|
|
||||||
def test_path_traversal_rejected(self):
|
|
||||||
boundary = b"----testboundary"
|
|
||||||
body = (
|
|
||||||
b"------testboundary\r\n"
|
|
||||||
b'Content-Disposition: form-data; name="files"; filename="../etc/passwd"\r\n\r\n'
|
|
||||||
b"evil"
|
|
||||||
b"\r\n------testboundary--\r\n"
|
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="invalid filename"):
|
|
||||||
parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""Unit tests for LLM client and Repository — with fakes."""
|
|
||||||
from compare.llm_client import FakeLLMClient
|
|
||||||
from repository import MemRepository
|
|
||||||
|
|
||||||
|
|
||||||
class TestFakeLLM:
|
|
||||||
def test_exact_match(self):
|
|
||||||
client = FakeLLMClient({"hello": "world"})
|
|
||||||
assert client.complete("hello") == "world"
|
|
||||||
|
|
||||||
def test_partial_match(self):
|
|
||||||
client = FakeLLMClient({"classify": '{"doc_type":"contract"}'})
|
|
||||||
assert "contract" in client.complete("classify this document")
|
|
||||||
|
|
||||||
def test_default_fallback(self):
|
|
||||||
client = FakeLLMClient({"default": '{"ok":true}'})
|
|
||||||
assert client.complete("unknown prompt") == '{"ok":true}'
|
|
||||||
|
|
||||||
def test_call_history(self):
|
|
||||||
client = FakeLLMClient({"a": "1", "b": "2"})
|
|
||||||
client.complete("a")
|
|
||||||
client.complete("b")
|
|
||||||
assert len(client.calls) == 2
|
|
||||||
assert client.calls[0] == "a"
|
|
||||||
|
|
||||||
|
|
||||||
class TestMemRepository:
|
|
||||||
def test_insert_and_retrieve(self, mem_repo):
|
|
||||||
doc = mem_repo.insert_document("test.docx", "application/vnd...", "base64data", batch_id="batch-1")
|
|
||||||
assert doc["filename"] == "test.docx"
|
|
||||||
assert doc["classify_status"] == "pending"
|
|
||||||
|
|
||||||
def test_list_pending(self, mem_repo):
|
|
||||||
mem_repo.insert_document("a.docx", "mime", "data", batch_id="b1")
|
|
||||||
mem_repo.insert_document("b.docx", "mime", "data", batch_id="b1")
|
|
||||||
mem_repo.insert_document("c.docx", "mime", "data", batch_id="b2")
|
|
||||||
pending = mem_repo.list_pending("b1")
|
|
||||||
assert len(pending) == 2
|
|
||||||
|
|
||||||
def test_set_classification(self, mem_repo):
|
|
||||||
doc = mem_repo.insert_document("test.docx", "mime", "data", batch_id="b1")
|
|
||||||
mem_repo.set_classification(doc["id"], "contract", "03700", None, "2026-02-01", "XXX")
|
|
||||||
updated = mem_repo.documents[doc["id"]]
|
|
||||||
assert updated["doc_type"] == "contract"
|
|
||||||
assert updated["classify_status"] == "classified"
|
|
||||||
|
|
||||||
def test_set_garbage(self, mem_repo):
|
|
||||||
doc = mem_repo.insert_document("счет.docx", "mime", "data", batch_id="b1")
|
|
||||||
mem_repo.set_classify_garbage(doc["id"], "filename_regex")
|
|
||||||
assert mem_repo.documents[doc["id"]]["doc_type"] == "garbage"
|
|
||||||
|
|
||||||
def test_contract_lifecycle(self, mem_repo):
|
|
||||||
cid = mem_repo.insert_contract("03700_1", "ЗАО XXX")
|
|
||||||
assert cid
|
|
||||||
doc = mem_repo.insert_document("test.docx", "mime", "data")
|
|
||||||
mem_repo.insert_supplement(cid, doc["id"], "initial")
|
|
||||||
supps = mem_repo.list_supplements(cid)
|
|
||||||
assert len(supps) == 1
|
|
||||||
assert supps[0]["type"] == "initial"
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Задание для Sonnet: описание архитектуры сервиса «Сверка договоров»
|
||||||
|
|
||||||
|
## Роль и задача
|
||||||
|
|
||||||
|
Ты — технический аналитик. Твоя задача — **только прочитать указанные файлы** и написать
|
||||||
|
подробное, структурированное описание сервиса «Сверка договоров»: что это за сервис, как
|
||||||
|
устроен, как работает каждый этап, какими методами и технологиями. **Ничего не менять и
|
||||||
|
не создавать в коде.** Результат — один текстовый документ (markdown).
|
||||||
|
|
||||||
|
## Короткий контекст (чтобы ты понимал, о чём речь)
|
||||||
|
|
||||||
|
Сервис «Сверка договоров» — веб-приложение на **Flask + vanilla JS** (без фреймворков
|
||||||
|
на фронте), развёрнутое на managed-кластере Nubes (Штурвал). Пользователь загружает
|
||||||
|
договоры и допсоглашения (docx/pdf/zip), сервис:
|
||||||
|
1. разбирает их на структурированные спецификации;
|
||||||
|
2. автоматически классифицирует документы (тип/номер/дата/контрагент) через LLM;
|
||||||
|
3. группирует документы по договорам;
|
||||||
|
4. по цепочке допников сравнивает изменения спецификации (ADD/UPDATE/DELETE) через LLM;
|
||||||
|
5. показывает историю изменений и даёт возможность задать вопрос чату по итоговой спецификации.
|
||||||
|
|
||||||
|
Ключевое ограничение: данные конфиденциальны, LLM — только своя (api.aillm.ru, модель
|
||||||
|
gpt-oss-120b). Большие файлы не грузятся напрямую в кластер (шлюз рвёт тела >~64 КБ) —
|
||||||
|
используется «ВМ-буфер»: браузер кладёт файл на ВМ (WebDAV), а бэк сам тянет его
|
||||||
|
исходящим запросом (egress не ограничен).
|
||||||
|
|
||||||
|
## Что прочитать — ТОЛЬКО эти файлы (корень проекта: contracts-flask/)
|
||||||
|
|
||||||
|
### Ядро бэкенда (обязательно, читать все)
|
||||||
|
- `site/app.py` — точка входа, фабрика приложения, sys.path
|
||||||
|
- `site/config.py` — конфигурация (версия, LLM_URL/KEY/MODEL, CONVERT_SERVICE_URL, VM_UPLOAD_*)
|
||||||
|
- `site/llm_prompt.py` — формирование промптов LLM (извлечение/сравнение/классификация)
|
||||||
|
- `site/routes/__init__.py` — регистрация всех blueprint'ов
|
||||||
|
- `site/routes/upload_bp.py` — загрузка файлов (multipart + sink закачки через ВМ)
|
||||||
|
- `site/routes/pipeline_bp.py` — классификация + SSE-сравнение (/process-v2, /api/classify-batch)
|
||||||
|
- `site/routes/api_bp.py` — API (groups, documents, supplements, sync, cleanup, spec-current, chat)
|
||||||
|
- `site/routes/pages_bp.py` — HTML-страницы + раздача ES-модулей upload/
|
||||||
|
- `site/routes/prompts_bp.py` — CRUD и версионирование промптов
|
||||||
|
- `site/routes/health_bp.py` — healthcheck
|
||||||
|
- `site/services/parse.py` — парсинг PDF (pdfplumber), DOCX (python-docx), TXT
|
||||||
|
- `site/services/classify.py` — LLM-классификация документов (garbage-фильтры, выжимка, ThreadPool)
|
||||||
|
- `site/services/grouping.py` — нормализация номеров и группировка по договорам
|
||||||
|
- `site/services/process.py` — SSE-пайплайн сравнения (run_pipeline)
|
||||||
|
- `site/services/llm.py` — вызов LLM для сравнения (call_llm)
|
||||||
|
- `site/services/llm_client.py` — LLM-клиент (HttpxLLMClient, FakeLLMClient для тестов)
|
||||||
|
- `site/services/metrics.py` — проверка арифметики (sum == price*qty), метрики качества
|
||||||
|
- `site/db/connection.py` — SQLite: схема всех таблиц, WAL, thread-local соединения
|
||||||
|
- `site/db/documents.py` — CRUD документов (статусы, классификация, elements_json)
|
||||||
|
- `site/db/contracts.py` — договоры
|
||||||
|
- `site/db/supplements.py` — допники (связь contract↔document)
|
||||||
|
- `site/db/spec_events.py` — event sourcing: apply_ops (ADD/UPDATE/DELETE), reset
|
||||||
|
- `site/db/spec_current.py` — текущее состояние спецификации договора
|
||||||
|
- `site/db/prompts.py` — версии промптов по ролям
|
||||||
|
|
||||||
|
### Фронтенд (для понимания потока на стороне браузера)
|
||||||
|
- `site/static/state.js` — центральное состояние приложения
|
||||||
|
- `site/static/app.js` — инициализация, обработчики загрузки/классификации/сравнения
|
||||||
|
- `site/static/files.js` — выбор файлов/папок/архивов, загрузка через ВМ-буфер
|
||||||
|
- `site/static/groups.js` — классификация и группы на фронте
|
||||||
|
- `site/static/compare.js` — сравнение (SSE) и рендер результатов
|
||||||
|
- `site/templates/index.html` — разметка UI
|
||||||
|
|
||||||
|
### Переиспользуемый модуль загрузки через ВМ (модуль upload/)
|
||||||
|
- `upload/README.md` — описание модуля и паттерна «ВМ-буфер + pull»
|
||||||
|
- `upload/backend/upload_refs/blueprint.py` — blueprint POST /api/upload_refs с параметром sink
|
||||||
|
- `upload/backend/upload_refs/safe_name.py`, `pull_file.py`, `config.py` — транспортные примитивы
|
||||||
|
- `upload/backend/session/__init__.py` — in-memory сессия (используется drhider; сверка его не использует)
|
||||||
|
|
||||||
|
### Инфраструктура
|
||||||
|
- `Dockerfile` — сборка образа
|
||||||
|
- `requirements.txt` — зависимости
|
||||||
|
|
||||||
|
## Что НЕ читать и не трогать
|
||||||
|
|
||||||
|
- `History/` — вся история сессий, ревью, старые планы (НЕ нужно)
|
||||||
|
- `contractor-legacy/` — замороженный Lucee-прод (не актуален)
|
||||||
|
- `contracts-vm/`, `convert-service/`, `loadtest/`, `testgen/`, `sim/`, `dogovora/`, `hz/`, `FILES/`, `DOC/` — смежные/вспомогательные, НЕ ядро сервиса
|
||||||
|
- `deploy/` — устаревший VM-слой (convert_server.py и т.п.), не входит в текущий managed-сервис
|
||||||
|
- `site/services/drhider.py` — совместимость с DrHider (побочное, не ядро сверки)
|
||||||
|
- `site/templates/drhider.html`, `architect.html`, `pipeline.html`, `ci-cd.html` — не ядро сверки
|
||||||
|
|
||||||
|
## Что должно быть в ответе (структура)
|
||||||
|
|
||||||
|
1. **Описание сервиса** — что делает, кому, какие входы/выходы (2-4 абзаца, по-простому).
|
||||||
|
2. **Общая архитектура** — слои (фронт / бэк / БД / LLM / ВМ-буфер), как они связаны.
|
||||||
|
Лучше — схема потока: загрузка → парсинг → классификация → группировка → сравнение → чат.
|
||||||
|
3. **Структура проекта** — дерево `site/` (+ `upload/`) с назначением каждого файла.
|
||||||
|
4. **Как работает каждый этап** (по пунктам, «что делает / как / где в коде»):
|
||||||
|
- загрузка файлов (включая папки/архивы, ВМ-буфер, sink);
|
||||||
|
- парсинг PDF/DOCX/TXT → elements (таблицы + параграфы);
|
||||||
|
- классификация (garbage-фильтры, выжимка, LLM, параллельность);
|
||||||
|
- группировка (нормализация номеров, виртуальные группы, __unresolved__);
|
||||||
|
- сравнение (SSE-пайплайн, ops ADD/UPDATE/DELETE, event sourcing, spec_current);
|
||||||
|
- чат по итоговой спецификации.
|
||||||
|
5. **Технологии и методы** — коротко: SQLite (WAL, thread-local), SSE, ThreadPool, LLM-клиент,
|
||||||
|
промпты (из БД + fallback), event sourcing, метрики качества, ВМ-буфер загрузки.
|
||||||
|
6. **Модель данных** — таблицы БД и их назначение (documents, contracts, supplements,
|
||||||
|
spec_events, spec_current, prompts).
|
||||||
|
|
||||||
|
## Требования к стилю
|
||||||
|
|
||||||
|
- Чётко, структурно, без воды и лирики.
|
||||||
|
- Без излишней заумности — понятно не только девопсу, но и обычному разработчику/аналитику.
|
||||||
|
- Каждый этап: «что делает → как → каким методом → где в коде (файл/функция)».
|
||||||
|
- Ничего не менять в коде. Только текстовый документ.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Задание для Sonnet (v2): описание архитектуры сервиса «Сверка договоров»
|
||||||
|
|
||||||
|
## Роль и задача
|
||||||
|
|
||||||
|
Ты — технический аналитик. Задача — **только прочитать указанные файлы** и написать
|
||||||
|
подробное, структурированное описание сервиса «Сверка договоров»: что это, как устроено,
|
||||||
|
как работает каждый этап, какими методами/технологиями. **Ничего не менять в коде.**
|
||||||
|
Результат — один текстовый документ (markdown).
|
||||||
|
|
||||||
|
## Контекст (что уже было)
|
||||||
|
|
||||||
|
Ранее ты выдал **код-ревью** с 10 находками (вместо описания). Все 10 уже **исправлены**
|
||||||
|
(v2.0.15): критичный баг `target_id` vs `target_hash` (частичная сверка), Dockerfile,
|
||||||
|
prompts CRUD, LLM-конфиг, XSS в `compare.js`, мелочи. Учти это — читай код **после** правок,
|
||||||
|
описывай **текущее** состояние.
|
||||||
|
|
||||||
|
## Сервис в двух словах
|
||||||
|
|
||||||
|
«Сверка договоров» — веб-приложение на **Flask + vanilla JS** (без фреймворков на фронте),
|
||||||
|
на managed-кластере Nubes. Пользователь загружает договоры/допсоглашения (docx/pdf/zip),
|
||||||
|
сервис: разбирает их в спецификации → классифицирует документы через LLM → группирует
|
||||||
|
по договорам → по цепочке допников сравнивает изменения (ADD/UPDATE/DELETE) через LLM →
|
||||||
|
показывает историю + чат по итоговой спецификации. Данные конфиденциальны, LLM — только
|
||||||
|
своя (api.aillm.ru, gpt-oss-120b). Большие файлы грузятся через «ВМ-буфер» (браузер PUT на
|
||||||
|
WebDAV → бэк тянет egress GET), т.к. шлюз кластера рвёт тела >~64 КБ.
|
||||||
|
|
||||||
|
## Что прочитать — ТОЛЬКО эти файлы (корень: contracts-flask/)
|
||||||
|
|
||||||
|
### Ядро бэкенда
|
||||||
|
- `site/app.py` — точка входа, фабрика приложения, sys.path
|
||||||
|
- `site/config.py` — конфигурация (версия, LLM, конвертер .doc, VM_UPLOAD_*)
|
||||||
|
- `site/llm_prompt.py` — формирование промптов LLM (extract/diff/classify + fallback)
|
||||||
|
- `site/routes/__init__.py` — регистрация blueprint'ов
|
||||||
|
- `site/routes/upload_bp.py` — загрузка (multipart + sink закачки через ВМ)
|
||||||
|
- `site/routes/pipeline_bp.py` — классификация + SSE-сравнение (/process-v2, /api/classify-batch)
|
||||||
|
- `site/routes/api_bp.py` — API (groups, documents, supplements, sync, cleanup, spec-current, chat)
|
||||||
|
- `site/routes/pages_bp.py` — HTML-страницы + раздача ES-модулей upload/
|
||||||
|
- `site/routes/prompts_bp.py` — CRUD и версионирование промптов
|
||||||
|
- `site/routes/health_bp.py` — healthcheck
|
||||||
|
- `site/services/parse.py` — парсинг PDF (pdfplumber), DOCX (python-docx), TXT
|
||||||
|
- `site/services/classify.py` — LLM-классификация (garbage-фильтры, выжимка, ThreadPool)
|
||||||
|
- `site/services/grouping.py` — нормализация номеров и группировка по договорам
|
||||||
|
- `site/services/process.py` — SSE-пайплайн сравнения (run_pipeline) + трансляция target_id→hash
|
||||||
|
- `site/services/llm.py` — вызов LLM для сравнения (call_llm)
|
||||||
|
- `site/services/llm_client.py` — LLM-клиент (HttpxLLMClient, FakeLLMClient)
|
||||||
|
- `site/services/metrics.py` — проверка арифметики (sum == price*qty), метрики качества
|
||||||
|
- `site/db/connection.py` — SQLite: схема, WAL, thread-local
|
||||||
|
- `site/db/documents.py` — CRUD документов
|
||||||
|
- `site/db/contracts.py` — договоры
|
||||||
|
- `site/db/supplements.py` — допники (contract↔document)
|
||||||
|
- `site/db/spec_events.py` — event sourcing: apply_ops (ADD/UPDATE/DELETE), reset
|
||||||
|
- `site/db/spec_current.py` — текущее состояние спецификации
|
||||||
|
- `site/db/prompts.py` — версии промптов
|
||||||
|
|
||||||
|
### Фронтенд
|
||||||
|
- `site/static/state.js` — центральное состояние
|
||||||
|
- `site/static/app.js` — инициализация, обработчики
|
||||||
|
- `site/static/files.js` — выбор файлов/папок/архивов, загрузка через ВМ
|
||||||
|
- `site/static/groups.js` — классификация и группы на фронте
|
||||||
|
- `site/static/compare.js` — сравнение (SSE) и рендер
|
||||||
|
- `site/templates/index.html` — UI
|
||||||
|
|
||||||
|
### Модуль upload/ (переиспользуемый)
|
||||||
|
- `upload/README.md` — паттерн «ВМ-буфер + pull»
|
||||||
|
- `upload/backend/upload_refs/blueprint.py` — blueprint POST /api/upload_refs с sink
|
||||||
|
- `upload/backend/upload_refs/safe_name.py`, `pull_file.py`, `config.py`
|
||||||
|
- `upload/backend/session/__init__.py` — in-memory сессия (drhider; сверка не использует)
|
||||||
|
|
||||||
|
### Инфраструктура
|
||||||
|
- `Dockerfile`, `requirements.txt`
|
||||||
|
|
||||||
|
## Что НЕ читать
|
||||||
|
|
||||||
|
`History/`, `contractor-legacy/`, `contracts-vm/`, `convert-service/`, `loadtest/`,
|
||||||
|
`testgen/`, `sim/`, `dogovora/`, `hz/`, `FILES/`, `DOC/`, `deploy/` (устаревший VM-слой),
|
||||||
|
`site/services/drhider.py`, `site/templates/drhider.html|architect.html|pipeline.html|ci-cd.html`.
|
||||||
|
|
||||||
|
## Что должно быть в ответе (структура)
|
||||||
|
|
||||||
|
1. **Описание сервиса** — что делает, входы/выходы (2–4 абзаца, по-простому).
|
||||||
|
2. **Общая архитектура** — слои (фронт/бэк/БД/LLM/ВМ-буфер) + схема потока:
|
||||||
|
загрузка → парсинг → классификация → группировка → сравнение → чат.
|
||||||
|
3. **Структура проекта** — дерево `site/` (+ `upload/`) с назначением каждого файла.
|
||||||
|
4. **Как работает каждый этап** — «что делает → как → каким методом → где в коде (файл/функция)».
|
||||||
|
5. **Технологии и методы** — SQLite (WAL, thread-local), SSE, ThreadPool, LLM-клиент,
|
||||||
|
промпты (БД + fallback), event sourcing, метрики качества, ВМ-буфер.
|
||||||
|
6. **Модель данных** — таблицы и назначение (documents, contracts, supplements,
|
||||||
|
spec_events, spec_current, prompts).
|
||||||
|
|
||||||
|
## Дополнительно: точечная проверка фикса #1
|
||||||
|
|
||||||
|
Отдельно, коротко (3–5 строк): в `site/services/process.py` глянь блок трансляции
|
||||||
|
`target_id ("r1","r2"…) → target_hash` (через `current_spec[_idx]["hash"]`). Подтверди:
|
||||||
|
корректно ли он маппит `rN` (1-based) на индекс `current_spec`, и что при невалидном id
|
||||||
|
операция уходит в UNRESOLVED, а не падает. Если видишь ошибку — укажи, но НЕ правь.
|
||||||
|
|
||||||
|
## Требования к стилю
|
||||||
|
|
||||||
|
- Чётко, структурно, без воды и лирики.
|
||||||
|
- Без излишней заумности — понятно не только девопсу, но и обычному разработчику/аналитику.
|
||||||
|
- Ничего не менять в коде. Только текстовый документ.
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Как работает сервис «Сверка договоров»
|
||||||
|
|
||||||
|
Документ описывает текущую программную реализацию `contracts-flask`: последовательность обработки данных, ответственность модулей и ключевые функции. Основной актуальный код находится в `site/`; каталог `deploy/` является историческим VM-слоем.
|
||||||
|
|
||||||
|
## 1. Общий поток
|
||||||
|
|
||||||
|
```text
|
||||||
|
Браузер
|
||||||
|
-> выбор файлов / ZIP
|
||||||
|
-> загрузка через VM-буфер
|
||||||
|
-> парсинг
|
||||||
|
-> классификация документов
|
||||||
|
-> группировка по договорам
|
||||||
|
-> применение подтверждённых групп
|
||||||
|
-> SSE-сравнение документов по порядку
|
||||||
|
-> event sourcing: spec_events + spec_current
|
||||||
|
-> таблица изменений и чат по текущей спецификации
|
||||||
|
```
|
||||||
|
|
||||||
|
Главная точка сборки Flask-приложения: [`site/app.py`](../site/app.py). Регистрация маршрутов выполняется через [`site/routes/__init__.py`](../site/routes/__init__.py).
|
||||||
|
|
||||||
|
## 2. Загрузка файлов
|
||||||
|
|
||||||
|
Фронтенд начинает обработку в [`site/static/files.js`](../site/static/files.js):
|
||||||
|
|
||||||
|
- `onFilesSelected()` принимает выбранные файлы, обрабатывает ZIP и обычные документы, запускает подготовку загрузки и сохраняет связь `zip_source`.
|
||||||
|
- `uploadFile()` запускает загрузку через VM-буфер; фактические PUT и отправка ссылок выполняются функциями `uploadViaVM()` и `putToVm()` в [`upload/frontend/upload/upload_via_vm.js`](../upload/frontend/upload/upload_via_vm.js) и [`upload/frontend/upload/put_to_vm.js`](../upload/frontend/upload/put_to_vm.js).
|
||||||
|
- `finalizeUpload()` завершает загрузку и обновляет состояние.
|
||||||
|
- `renderFiles(state)` отображает список файлов, группируя вложения по `zip_source`.
|
||||||
|
- `syncDB()` синхронизирует состав файлов в БД.
|
||||||
|
|
||||||
|
Из-за ограничения managed-шлюза большие файлы сначала передаются на ВМ, а затем бэкенд забирает их исходящим запросом. Общий переиспользуемый транспорт находится в [`upload/backend/upload_refs/blueprint.py`](../upload/backend/upload_refs/blueprint.py), включая обработчик `POST /api/upload_refs`; конкретная бизнес-логика передаётся через `sink`.
|
||||||
|
|
||||||
|
Маршруты загрузки основного приложения находятся в [`site/routes/upload_bp.py`](../site/routes/upload_bp.py). Там принимаются ссылки/файлы, вызывается sink сервиса договоров и сохраняются результаты обработки.
|
||||||
|
|
||||||
|
## 3. Парсинг
|
||||||
|
|
||||||
|
Парсер находится в [`site/services/parse.py`](../site/services/parse.py). Он преобразует содержимое документов в структурированный список элементов:
|
||||||
|
|
||||||
|
- `parse()` выбирает обработчик по формату.
|
||||||
|
- Обработчик DOCX использует `python-docx` и извлекает параграфы и таблицы.
|
||||||
|
- Обработчик PDF использует `pdfplumber`.
|
||||||
|
- TXT обрабатывается напрямую.
|
||||||
|
- Для `.doc` функция `parse_file()` в текущем `site` вызывает `_parse_docx(data)`; отдельный [`convert-service/app.py`](../convert-service/app.py) и исторический [`deploy/convert_doc.py`](../deploy/convert_doc.py) не являются частью этого вызова.
|
||||||
|
|
||||||
|
Результат парсинга сохраняется как `elements_json`. Важный принцип: парсер не решает, какие строки являются услугами, а сохраняет исходную структуру документа для следующих стадий.
|
||||||
|
|
||||||
|
## 4. Классификация документов
|
||||||
|
|
||||||
|
Маршрут запуска классификации: [`site/routes/pipeline_bp.py`](../site/routes/pipeline_bp.py), функция `classify_batch_route()`.
|
||||||
|
|
||||||
|
- Для небольшого батча классификация выполняется синхронно.
|
||||||
|
- Для большого батча запускается фоновый поток и возвращается `202`.
|
||||||
|
- `process_v2()` отдаёт поток SSE для этапа сравнения.
|
||||||
|
|
||||||
|
Основная логика находится в [`site/services/classify.py`](../site/services/classify.py):
|
||||||
|
|
||||||
|
- `classify_batch(batch_id, llm_client=None, repo=None)` получает pending-документы, запускает параллельную обработку через `ThreadPoolExecutor` и сохраняет результат.
|
||||||
|
- `_classify_one()` выполняет полный цикл для одного файла.
|
||||||
|
- `_is_garbage_by_filename()` отбрасывает очевидный мусор по имени файла.
|
||||||
|
- `_smart_extract()` формирует компактную выжимку: начало документа и найденные фрагменты с ключевыми маркерами.
|
||||||
|
- `_is_garbage_by_header()` отбрасывает документы по заголовочным маркерам.
|
||||||
|
- `_call_llm_classify()` отправляет выжимку в LLM.
|
||||||
|
- `_safe_json_parse()` извлекает JSON из ответа LLM и исправляет распространённые ошибки форматирования.
|
||||||
|
|
||||||
|
Промпт формируется функцией `build_classify_prompt()` в [`site/llm_prompt.py`](../site/llm_prompt.py). Результат содержит `doc_type`, `own_number`, `parent_number`, `doc_date` и `counterparty`. Сырые вход и ответ LLM также сохраняются для диагностики.
|
||||||
|
|
||||||
|
## 5. Группировка по договорам
|
||||||
|
|
||||||
|
Логика находится в [`site/services/grouping.py`](../site/services/grouping.py):
|
||||||
|
|
||||||
|
- `normalize_number(num)` приводит номер к верхнему регистру и убирает разделители.
|
||||||
|
- `group_documents(batch_id)` отделяет договоры от допников/спецификаций, сопоставляет их по `parent_number` или `own_number`, создаёт виртуальные группы при отсутствии базового договора и помещает нерешённые документы в `__unresolved__`.
|
||||||
|
- Внутри групп документы сортируются по `doc_date`.
|
||||||
|
- `apply_groups(batch_id, groups_data)` сохраняет подтверждённые группы в таблицы договоров и допников.
|
||||||
|
|
||||||
|
Маршруты чтения и применения групп находятся в [`site/routes/api_bp.py`](../site/routes/api_bp.py). Фронтенд отображает результат через [`site/static/groups.js`](../site/static/groups.js).
|
||||||
|
|
||||||
|
Таким образом, LLM извлекает признаки документа, но окончательное сопоставление по номерам выполняется обычным Python-кодом.
|
||||||
|
|
||||||
|
## 6. Последовательное сравнение
|
||||||
|
|
||||||
|
Маршрут сравнения: `process_v2()` в [`site/routes/pipeline_bp.py`](../site/routes/pipeline_bp.py). Он проверяет `contract_id`, запускает `run_pipeline()` и преобразует события в формат SSE.
|
||||||
|
|
||||||
|
Основной pipeline находится в [`site/services/process.py`](../site/services/process.py):
|
||||||
|
|
||||||
|
- `run_pipeline(contract_id, order_ids, build_prompt_fn)` сбрасывает предыдущее состояние, получает документы группы и определяет порядок обработки.
|
||||||
|
- `_elements_to_text(ej)` преобразует `elements_json` в текстовый контекст для LLM.
|
||||||
|
- Для каждого документа читается текущая спецификация.
|
||||||
|
- `call_llm()` из [`site/services/llm.py`](../site/services/llm.py) получает текущие строки и текст документа и возвращает операции.
|
||||||
|
- `target_id` вида `r1`, `r2` переводится в `target_hash` соответствующей строки текущей спецификации.
|
||||||
|
- Для режима `full_replace` вызывается `clear_current()`, чтобы новая редакция не дублировала старые строки.
|
||||||
|
- Затем вызывается `apply_ops()` и отправляются SSE-события `extract_start`, `llm_done`, `applied`, `extract_error` и финальное `complete`.
|
||||||
|
- `check_arithmetic()` из [`site/services/metrics.py`](../site/services/metrics.py) проверяет согласованность `sum`, `price` и `qty`.
|
||||||
|
|
||||||
|
Промпты извлечения и сравнения формируются через `build_prompt()` в [`site/llm_prompt.py`](../site/llm_prompt.py); версии промптов хранятся и редактируются маршрутами из [`site/routes/prompts_bp.py`](../site/routes/prompts_bp.py).
|
||||||
|
|
||||||
|
## 7. Event sourcing и текущее состояние
|
||||||
|
|
||||||
|
Механизм хранения находится в [`site/db/spec_events.py`](../site/db/spec_events.py):
|
||||||
|
|
||||||
|
- `reset(contract_id)` очищает события и текущее состояние перед новым полным прогоном.
|
||||||
|
- `clear_current(contract_id)` очищает только текущую спецификацию для `full_replace`.
|
||||||
|
- `apply_ops(...)` обрабатывает `ADD`, `UPDATE`, `DELETE` и `UNRESOLVED`.
|
||||||
|
- `_hash(name, date_start)` создаёт стабильный идентификатор строки услуги.
|
||||||
|
- `_upsert_spec_current(...)` добавляет или обновляет строку текущей спецификации.
|
||||||
|
- `_update_spec_current(...)` изменяет только поля, указанные в операции.
|
||||||
|
- `_log_unresolved(...)` сохраняет нерешённую операцию вместо молчаливого пропуска.
|
||||||
|
|
||||||
|
`spec_events` — журнал операций с исходным ответом LLM, версией промпта и ссылкой на документ. `spec_current` — материализованное состояние, используемое для следующего сравнения и отображения.
|
||||||
|
|
||||||
|
CRUD текущей спецификации и исходных данных документа находится в [`site/db/spec_current.py`](../site/db/spec_current.py). Схема и соединения описаны в [`site/db/connection.py`](../site/db/connection.py).
|
||||||
|
|
||||||
|
## 8. SSE и интерфейс
|
||||||
|
|
||||||
|
Клиент сравнения находится в [`site/static/compare.js`](../site/static/compare.js):
|
||||||
|
|
||||||
|
- `startCompareSSE()` открывает `EventSource`, принимает события и управляет таймером.
|
||||||
|
- `applyCompareEvent()` переводит SSE-события в состояние секций.
|
||||||
|
- `renderCompareSectionHeader()` формирует заголовок секции документа.
|
||||||
|
- `renderCompareSectionBody()` отображает итоговую статистику и операции.
|
||||||
|
- `renderCompareOpsTable()` строит таблицу изменений.
|
||||||
|
|
||||||
|
Оркестрация шагов загрузки, классификации, группировки и запуска сравнения находится в [`site/static/app.js`](../site/static/app.js), в частности в `runClassify()` и обработчиках `loadGroupsAction()`/сравнения.
|
||||||
|
|
||||||
|
## 9. Чат
|
||||||
|
|
||||||
|
Маршрут чата находится в [`site/routes/api_bp.py`](../site/routes/api_bp.py). Он получает вопрос пользователя, загружает текущую спецификацию через функции из [`site/db/spec_current.py`](../site/db/spec_current.py), формирует контекст и отправляет его в LLM. Поэтому чат отвечает по материализованному состоянию договора, а не по случайному отдельному документу.
|
||||||
|
|
||||||
|
## 10. Итоговая ответственность компонентов
|
||||||
|
|
||||||
|
| Слой | Ответственность |
|
||||||
|
|---|---|
|
||||||
|
| `site/static/*.js` | выбор файлов, загрузка, отображение прогресса и результатов |
|
||||||
|
| `site/routes/` | HTTP API, SSE и связывание компонентов |
|
||||||
|
| `site/services/parse.py` | извлечение структуры документа |
|
||||||
|
| `site/services/classify.py` | классификация и выделение метаданных через LLM |
|
||||||
|
| `site/services/grouping.py` | детерминированное сопоставление документов |
|
||||||
|
| `site/services/process.py` | последовательное сравнение группы |
|
||||||
|
| `site/services/llm.py` | вызов LLM для анализа |
|
||||||
|
| `site/db/spec_events.py` | применение операций и аудит изменений |
|
||||||
|
| `site/db/spec_current.py` | чтение текущей спецификации и элементов документов |
|
||||||
|
| `site/db/connection.py` | SQLite/WAL и доступ к данным |
|
||||||
|
|
||||||
|
Итог: LLM используется там, где требуется понять содержание документа и смысл изменения услуги. Структура pipeline, нормализация номеров, порядок, проверки и сохранение результата выполняются детерминированным кодом.
|
||||||
+37
-116
@@ -1,128 +1,49 @@
|
|||||||
"""contracts-flask — Flask-фронтенд для сверки договоров.
|
"""contracts-flask v2.0 — полный перенос с ВМ на Flask.
|
||||||
1:1 функционал с Lucee-версией.
|
Больше никаких прокси на contracts.kube5s.ru — всё локально.
|
||||||
Все тяжёлые операции — на ВМ (contracts.kube5s.ru).
|
|
||||||
"""
|
"""
|
||||||
import os
|
import sys, os
|
||||||
import httpx
|
# site/ в sys.path — импортируем модули напрямую, без префиксов
|
||||||
from flask import Flask, render_template, request, jsonify
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
# корень репо — для переиспользуемого модуля upload/ (как в drhider)
|
||||||
|
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _REPO_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _REPO_ROOT)
|
||||||
|
|
||||||
app = Flask(__name__)
|
from flask import Flask
|
||||||
|
from config import VERSION, MAX_CONTENT_LENGTH
|
||||||
VM_API = os.environ.get("VM_API", "https://contracts.kube5s.ru")
|
from routes import register_routes
|
||||||
LLM_URL = os.environ.get("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
|
|
||||||
LLM_KEY = os.environ.get("LLM_API_KEY", "")
|
|
||||||
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss-120b")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
def create_app():
|
||||||
def index():
|
app = Flask(__name__)
|
||||||
return render_template("index.html", vm_api=VM_API)
|
app.config["VERSION"] = VERSION
|
||||||
|
app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
|
||||||
|
|
||||||
|
_init_db()
|
||||||
|
register_routes(app)
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def no_cache(response):
|
||||||
|
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
response.headers["Expires"] = "0"
|
||||||
|
return response
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
@app.route("/chat", methods=["POST"])
|
def _init_db():
|
||||||
def chat():
|
"""Создать БД + схему + seed prompts. SQLite — всё в одном файле /tmp."""
|
||||||
contract_id = request.args.get("contract_id", "")
|
from db.connection import init_db
|
||||||
question = request.form.get("question", "")
|
init_db()
|
||||||
if not contract_id or not question:
|
|
||||||
return jsonify({"ok": False, "error": "contract_id and question required"})
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=10) as client:
|
from db import prompts as db_prompts
|
||||||
resp = client.get(f"{VM_API}/api/spec-current", params={"contract_id": contract_id})
|
db_prompts.seed_defaults()
|
||||||
resp.raise_for_status()
|
except Exception:
|
||||||
rows = resp.json().get("rows", [])
|
pass
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"ok": False, "error": f"VM error: {e}"})
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return jsonify({"ok": True, "answer": "Нет данных. Сначала запустите сравнение."})
|
|
||||||
|
|
||||||
ctx = "\n".join(f"{r.get('name','')} | цена={r.get('price')} | объём={r.get('qty')} | сумма={r.get('sum')} | начало={r.get('date_start')}" for r in rows)
|
|
||||||
prompt = f"Ты — анализатор договоров. Данные:\n{ctx}\n\nВопрос: {question}\nОтветь кратко, только по данным."
|
|
||||||
|
|
||||||
try:
|
|
||||||
with httpx.Client(http2=True, timeout=120) as client:
|
|
||||||
resp = client.post(LLM_URL, json={
|
|
||||||
"model": LLM_MODEL,
|
|
||||||
"messages": [{"role": "user", "content": prompt}],
|
|
||||||
"max_tokens": 1000, "temperature": 0.1,
|
|
||||||
}, headers={"Authorization": f"Bearer {LLM_KEY}", "Content-Type": "application/json"})
|
|
||||||
resp.raise_for_status()
|
|
||||||
return jsonify({"ok": True, "answer": resp.json()["choices"][0]["message"]["content"]})
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"ok": False, "error": f"LLM error: {e}"})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/prompts", methods=["GET"])
|
app = create_app()
|
||||||
def prompts_get():
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=10) as client:
|
|
||||||
resp = client.get(f"{VM_API}/api/prompts", params=request.args)
|
|
||||||
return jsonify(resp.json())
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"ok": False, "error": str(e)})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/prompts/list", methods=["GET"])
|
|
||||||
def prompts_list():
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=10) as client:
|
|
||||||
resp = client.get(f"{VM_API}/api/prompts/list")
|
|
||||||
return jsonify(resp.json())
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"ok": False, "error": str(e)})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/prompts/save", methods=["POST"])
|
|
||||||
def prompts_save():
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=10) as client:
|
|
||||||
resp = client.post(f"{VM_API}/api/prompts/save", json=request.get_json())
|
|
||||||
return jsonify(resp.json())
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"ok": False, "error": str(e)})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/prompts/activate", methods=["POST"])
|
|
||||||
def prompts_activate():
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=10) as client:
|
|
||||||
resp = client.post(f"{VM_API}/api/prompts/activate", json=request.get_json())
|
|
||||||
return jsonify(resp.json())
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"ok": False, "error": str(e)})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/architect")
|
|
||||||
def architect():
|
|
||||||
return render_template("architect.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/ci-cd")
|
|
||||||
def ci_cd():
|
|
||||||
return render_template("ci-cd.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/drhider")
|
|
||||||
@app.route("/DrHider")
|
|
||||||
def drhider():
|
|
||||||
return render_template("drhider.html", vm_api=VM_API)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/health")
|
|
||||||
def health():
|
|
||||||
return {"ok": True, "service": "contracts-flask"}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=5000, threaded=True)
|
app.run(host="0.0.0.0", port=5000, threaded=True)
|
||||||
|
|
||||||
# ── Совместимость с Lucee-путями ──────────────────────────────
|
|
||||||
|
|
||||||
@app.route("/chat.cfm", methods=["POST"])
|
|
||||||
def chat_cfm():
|
|
||||||
return chat()
|
|
||||||
|
|
||||||
@app.route("/prompt.cfm", methods=["GET"])
|
|
||||||
def prompt_cfm():
|
|
||||||
return prompts_get()
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Конфигурация приложения — все настройки в одном месте."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
VERSION = "2.0.21"
|
||||||
|
|
||||||
|
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
|
||||||
|
LLM_KEY = os.getenv("LLM_API_KEY", "")
|
||||||
|
LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b")
|
||||||
|
|
||||||
|
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
|
||||||
|
API_KEY = os.getenv("API_KEY", "")
|
||||||
|
CONVERT_SERVICE_URL = os.getenv("CONVERT_SERVICE_URL", "http://containerk8s.df36c8af-1a95-4623-b551-0d37b731ccca.svc.cluster.local:5000")
|
||||||
|
|
||||||
|
# ── VM-буфер загрузки (паттерн drhider) ──────────────────────────────
|
||||||
|
# Браузер кладёт файл на ВМ через WebDAV (мимо шлюза кластера ~64КБ),
|
||||||
|
# бэк сам тянет его исходящим GET (egress без лимита).
|
||||||
|
VM_UPLOAD_URL = os.getenv("VM_UPLOAD_URL", "https://contracts.kube5s.ru/contracts-upload/")
|
||||||
|
# SSRF-защита: тянуть можно ТОЛЬКО с этого префикса.
|
||||||
|
VM_UPLOAD_PREFIX = os.getenv("VM_UPLOAD_PREFIX", "https://contracts.kube5s.ru/contracts-upload/")
|
||||||
|
# Лимит на один файл (совпадает с фронтом).
|
||||||
|
VM_UPLOAD_MAX_BYTES = int(os.getenv("VM_UPLOAD_MAX_BYTES", str(50 * 1024 * 1024)))
|
||||||
|
# Ретраи pull с ВМ (разовые DNS/сетевые сбои не роняют загрузку).
|
||||||
|
PULL_RETRIES = int(os.getenv("PULL_RETRIES", "3"))
|
||||||
|
PULL_RETRY_DELAY = 2.0
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Database connection — SQLite with thread-local connections.
|
||||||
|
|
||||||
|
Архитектура (Sonnet review 2026-07-15):
|
||||||
|
- threading.local() — каждому потоку своё соединение
|
||||||
|
- WAL-режим — readers не блокируют writer
|
||||||
|
- _db_session_key — защита от inode split-brain при cleanup+reuse потоков
|
||||||
|
- busy_timeout=5000 — ждать при конкурентной записи
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
DB_PATH = "/tmp/contracts.db"
|
||||||
|
_db_session_key = None
|
||||||
|
_local = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction():
|
||||||
|
"""Run DB writes atomically on the current thread connection."""
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
_local.in_transaction = True
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
_local.in_transaction = False
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Создать/пересоздать БД + схему. Вызывается при старте и после cleanup."""
|
||||||
|
global _db_session_key
|
||||||
|
_db_session_key = time.time()
|
||||||
|
|
||||||
|
# Удалить старый файл + WAL-сателлиты
|
||||||
|
for f in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm"):
|
||||||
|
try:
|
||||||
|
os.remove(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
conn = get_conn()
|
||||||
|
_create_schema(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _create_schema(conn):
|
||||||
|
"""Создать все таблицы (idempotent)."""
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime_type TEXT DEFAULT 'application/octet-stream',
|
||||||
|
original_bytes TEXT,
|
||||||
|
status TEXT DEFAULT 'uploaded',
|
||||||
|
error_message TEXT,
|
||||||
|
elements_json TEXT,
|
||||||
|
batch_id TEXT,
|
||||||
|
zip_source TEXT,
|
||||||
|
content_hash TEXT,
|
||||||
|
classify_status TEXT DEFAULT 'pending',
|
||||||
|
doc_type TEXT,
|
||||||
|
own_number TEXT,
|
||||||
|
parent_number TEXT,
|
||||||
|
doc_date TEXT,
|
||||||
|
counterparty TEXT,
|
||||||
|
classify_raw TEXT,
|
||||||
|
classify_input TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS contracts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
number TEXT NOT NULL,
|
||||||
|
client TEXT DEFAULT '',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS supplements (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
contract_id TEXT NOT NULL REFERENCES contracts(id),
|
||||||
|
document_id TEXT NOT NULL REFERENCES documents(id),
|
||||||
|
type TEXT DEFAULT 'additional',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spec_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
contract_id TEXT NOT NULL,
|
||||||
|
supplement_id TEXT NOT NULL,
|
||||||
|
seq INTEGER NOT NULL DEFAULT 0,
|
||||||
|
action TEXT NOT NULL DEFAULT 'UNRESOLVED',
|
||||||
|
target_hash TEXT DEFAULT '',
|
||||||
|
new_values TEXT DEFAULT '{}',
|
||||||
|
comment TEXT DEFAULT '',
|
||||||
|
status TEXT DEFAULT 'unresolved',
|
||||||
|
prompt_version TEXT DEFAULT '',
|
||||||
|
source_document_id TEXT DEFAULT '',
|
||||||
|
raw_llm_response TEXT DEFAULT '{}',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spec_current (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
contract_id TEXT NOT NULL,
|
||||||
|
name_hash TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
price REAL,
|
||||||
|
qty REAL,
|
||||||
|
sum REAL,
|
||||||
|
date_start TEXT,
|
||||||
|
last_event_id TEXT,
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS prompts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
name TEXT DEFAULT '',
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
is_active INTEGER DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS upload_chunks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
upload_id TEXT NOT NULL,
|
||||||
|
chunk_index INTEGER NOT NULL,
|
||||||
|
data TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_batch ON documents(batch_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(batch_id, content_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplements_contract ON supplements(contract_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spec_current_contract ON spec_current(contract_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spec_events_supplement ON spec_events(supplement_id);
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_db():
|
||||||
|
"""Удалить БД полностью. Вызывает init_db() для создания новой."""
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
"""Thread-local соединение. Пересоздаётся при смене сессии."""
|
||||||
|
global _db_session_key
|
||||||
|
|
||||||
|
conn = getattr(_local, "conn", None)
|
||||||
|
local_key = getattr(_local, "session_key", None)
|
||||||
|
|
||||||
|
if conn is None or local_key != _db_session_key:
|
||||||
|
if conn is not None:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=5, check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
_local.conn = conn
|
||||||
|
_local.session_key = _db_session_key
|
||||||
|
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def query(sql, params=None):
|
||||||
|
"""SELECT → list[dict]."""
|
||||||
|
conn = get_conn()
|
||||||
|
sql = _pg_to_sqlite(sql)
|
||||||
|
cur = conn.execute(sql, params or [])
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def execute(sql, params=None):
|
||||||
|
"""INSERT/UPDATE/DELETE → rowcount."""
|
||||||
|
conn = get_conn()
|
||||||
|
sql = _pg_to_sqlite(sql)
|
||||||
|
cur = conn.execute(sql, params or [])
|
||||||
|
if not getattr(_local, "in_transaction", False):
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def execute_returning(sql, params=None):
|
||||||
|
"""INSERT с RETURNING → dict (эмулируется через lastrowid)."""
|
||||||
|
conn = get_conn()
|
||||||
|
table = _extract_table(sql)
|
||||||
|
sql = _pg_to_sqlite(sql)
|
||||||
|
|
||||||
|
if " RETURNING " in sql.upper():
|
||||||
|
sql = sql[:sql.upper().rfind(" RETURNING ")]
|
||||||
|
|
||||||
|
cur = conn.execute(sql, params or [])
|
||||||
|
conn.commit()
|
||||||
|
rowid = cur.lastrowid
|
||||||
|
|
||||||
|
if table and rowid:
|
||||||
|
row = conn.execute(f"SELECT * FROM {table} WHERE rowid = ?", (rowid,)).fetchone()
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
# Fallback: если нет таблицы или rowid — просто вернуть последнюю строку
|
||||||
|
if table:
|
||||||
|
row = conn.execute(f"SELECT * FROM {table} ORDER BY rowid DESC LIMIT 1").fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pg_to_sqlite(sql):
|
||||||
|
"""Конвертировать PostgreSQL-специфичный SQL в SQLite."""
|
||||||
|
# %s → ?
|
||||||
|
sql = sql.replace("%s", "?")
|
||||||
|
# ::jsonb → убрать (SQLite не типизирует)
|
||||||
|
sql = sql.replace("::jsonb", "")
|
||||||
|
# BOOLEAN → INTEGER
|
||||||
|
sql = sql.replace(" BOOLEAN ", " INTEGER ")
|
||||||
|
sql = sql.replace(" bool ", " INTEGER ")
|
||||||
|
# ILIKE → LIKE (SQLite LIKE case-insensitive для ASCII)
|
||||||
|
sql = sql.replace(" ILIKE ", " LIKE ")
|
||||||
|
# FALSE/TRUE → 0/1
|
||||||
|
sql = sql.replace(" FALSE", " 0").replace(" TRUE", " 1")
|
||||||
|
sql = sql.replace(" false", " 0").replace(" true", " 1")
|
||||||
|
return sql
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_table(sql):
|
||||||
|
"""Извлечь имя таблицы из INSERT INTO <table>."""
|
||||||
|
import re
|
||||||
|
m = re.search(r"INSERT\s+INTO\s+(\w+)", sql, re.IGNORECASE)
|
||||||
|
return m.group(1) if m else None
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Contracts CRUD."""
|
||||||
|
import uuid
|
||||||
|
from db.connection import query, execute
|
||||||
|
|
||||||
|
|
||||||
|
def insert(number, client=""):
|
||||||
|
cid = str(uuid.uuid4())
|
||||||
|
execute(
|
||||||
|
"INSERT INTO contracts (id, number, client) VALUES (%s, %s, %s)",
|
||||||
|
(cid, number, client),
|
||||||
|
)
|
||||||
|
return get(cid)
|
||||||
|
|
||||||
|
|
||||||
|
def get(contract_id):
|
||||||
|
rows = query("SELECT * FROM contracts WHERE id = %s", (contract_id,))
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
def delete(contract_id):
|
||||||
|
return execute("DELETE FROM contracts WHERE id = %s", (contract_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def delete_orphaned():
|
||||||
|
"""Remove contracts with no supplements."""
|
||||||
|
execute(
|
||||||
|
"DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)"
|
||||||
|
)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Documents CRUD."""
|
||||||
|
import uuid
|
||||||
|
from db.connection import query, execute, execute_returning
|
||||||
|
|
||||||
|
|
||||||
|
def insert(filename, mime_type, original_bytes, status="uploaded", batch_id=None, zip_source=None, content_hash=None):
|
||||||
|
"""Insert document, return row dict."""
|
||||||
|
doc_id = str(uuid.uuid4())
|
||||||
|
execute(
|
||||||
|
"""INSERT INTO documents (id, filename, mime_type, original_bytes, status, batch_id, zip_source, content_hash)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||||
|
(doc_id, filename, mime_type, original_bytes, status, batch_id, zip_source, content_hash),
|
||||||
|
)
|
||||||
|
return get(doc_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get(doc_id):
|
||||||
|
rows = query("SELECT * FROM documents WHERE id = %s", (doc_id,))
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_hash(batch_id, content_hash):
|
||||||
|
"""Find document by content hash within batch."""
|
||||||
|
rows = query(
|
||||||
|
"SELECT id FROM documents WHERE batch_id = %s AND content_hash = %s LIMIT 1",
|
||||||
|
(batch_id, content_hash),
|
||||||
|
)
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_parsed(doc_id, elements_json):
|
||||||
|
"""Update elements_json + status='parsed'."""
|
||||||
|
import json
|
||||||
|
return execute(
|
||||||
|
"UPDATE documents SET elements_json = %s::jsonb, status = 'parsed' WHERE id = %s",
|
||||||
|
(json.dumps(elements_json, ensure_ascii=False), doc_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_error(doc_id, error_message):
|
||||||
|
return execute(
|
||||||
|
"UPDATE documents SET status = 'error', error_message = %s WHERE id = %s",
|
||||||
|
(error_message, doc_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete(doc_id):
|
||||||
|
return execute("DELETE FROM documents WHERE id = %s", (doc_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def set_classification(doc_id, doc_type, own_number, parent_number, doc_date, counterparty, classify_raw=None, classify_input=None):
|
||||||
|
"""Store LLM classification results + raw response + input text."""
|
||||||
|
return execute(
|
||||||
|
"""UPDATE documents SET doc_type=%s, own_number=%s, parent_number=%s,
|
||||||
|
doc_date=%s, counterparty=%s, classify_status='classified', classify_raw=%s, classify_input=%s
|
||||||
|
WHERE id=%s""",
|
||||||
|
(doc_type, own_number, parent_number, doc_date, counterparty, classify_raw, classify_input, doc_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_classify_failed(doc_id, error):
|
||||||
|
return execute(
|
||||||
|
"UPDATE documents SET classify_status='failed', error_message=%s WHERE id=%s",
|
||||||
|
(error, doc_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_classify_garbage(doc_id, reason=""):
|
||||||
|
"""Mark document as garbage (Stage 1-2 filter, no LLM call)."""
|
||||||
|
return execute(
|
||||||
|
"UPDATE documents SET doc_type='garbage', classify_status='garbage', error_message=%s WHERE id=%s",
|
||||||
|
(f"garbage: {reason}", doc_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_pending(batch_id):
|
||||||
|
"""Documents waiting for classification."""
|
||||||
|
return query(
|
||||||
|
"SELECT id, filename, elements_json FROM documents WHERE batch_id=%s AND classify_status='pending'",
|
||||||
|
(batch_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_classify_status(batch_id):
|
||||||
|
"""Сбросить classify_status на 'pending' только для 'processing' (crash recovery).
|
||||||
|
Уже классифицированные ('classified', 'garbage', 'failed') НЕ трогаем."""
|
||||||
|
return execute(
|
||||||
|
"UPDATE documents SET classify_status='pending', error_message=NULL WHERE batch_id=%s AND classify_status='processing'",
|
||||||
|
(batch_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_classify_processing(doc_id):
|
||||||
|
"""Mark document as being processed (for crash recovery)."""
|
||||||
|
return execute(
|
||||||
|
"UPDATE documents SET classify_status='processing' WHERE id=%s",
|
||||||
|
(doc_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_by_batch(batch_id):
|
||||||
|
"""All documents in a batch with classification fields."""
|
||||||
|
return query(
|
||||||
|
"""SELECT id, filename, status, doc_type, own_number, parent_number,
|
||||||
|
doc_date, counterparty, classify_status, error_message, classify_raw, classify_input,
|
||||||
|
zip_source
|
||||||
|
FROM documents WHERE batch_id=%s ORDER BY created_at""",
|
||||||
|
(batch_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_by_status(batch_id):
|
||||||
|
"""Count documents by classify_status."""
|
||||||
|
rows = query(
|
||||||
|
"SELECT classify_status, count(*) as cnt FROM documents WHERE batch_id=%s GROUP BY classify_status",
|
||||||
|
(batch_id,),
|
||||||
|
)
|
||||||
|
return {r["classify_status"]: r["cnt"] for r in rows}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Prompts CRUD."""
|
||||||
|
import uuid
|
||||||
|
from db.connection import query, execute
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(row):
|
||||||
|
"""Convert datetime fields to strings for JSON serialization (non-mutating)."""
|
||||||
|
if row and row.get("created_at"):
|
||||||
|
row = dict(row)
|
||||||
|
row["created_at"] = str(row["created_at"])
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def get_active(role):
|
||||||
|
rows = query(
|
||||||
|
"SELECT * FROM prompts WHERE role = %s AND is_active = true ORDER BY created_at DESC LIMIT 1",
|
||||||
|
(role,),
|
||||||
|
)
|
||||||
|
return _serialize(rows[0]) if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
def get(prompt_id):
|
||||||
|
rows = query("SELECT * FROM prompts WHERE id = %s", (prompt_id,))
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
def seed_defaults():
|
||||||
|
"""Auto-seed default prompts if table is empty."""
|
||||||
|
# Check each role separately — don't skip if one is missing
|
||||||
|
existing_roles = set(r["role"] for r in query("SELECT DISTINCT role FROM prompts"))
|
||||||
|
|
||||||
|
if "extract" not in existing_roles:
|
||||||
|
extract_body = (
|
||||||
|
"Ты — анализатор договоров облачного провайдера.\n\n"
|
||||||
|
"Ниже текст спецификации услуг из ПЕРВОГО документа (базовый договор).\n"
|
||||||
|
"Извлеки ВСЕ строки спецификации в JSON-массив.\n\n"
|
||||||
|
"Верни СТРОГО JSON без пояснений. Не используй markdown-блоки.\n\n"
|
||||||
|
"ФОРМАТ:\n"
|
||||||
|
'{\n "mode": "partial",\n "ops": [\n'
|
||||||
|
' {"action": "ADD", "new_row": {"name": "полное наименование", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}, "comment": ""}\n'
|
||||||
|
" ]\n}\n\n"
|
||||||
|
"ПРАВИЛА:\n"
|
||||||
|
"1. Извлеки КАЖДУЮ строку таблицы спецификации как отдельную ADD-операцию.\n"
|
||||||
|
'2. Пропускай итоговые строки ("Итого...") и строки с подписями.\n'
|
||||||
|
"3. Если ячейка пустая — ставь null (не пиши 0).\n"
|
||||||
|
"4. price, qty, sum — ЧИСЛА, не строки.\n\n"
|
||||||
|
"ТЕКСТ ДОКУМЕНТА:\n---\n{doc_text}\n---"
|
||||||
|
)
|
||||||
|
execute(
|
||||||
|
"INSERT INTO prompts (id, role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s, %s)",
|
||||||
|
(str(uuid.uuid4()), "extract", "default-v1", extract_body, True, "Авто-создан из llm_prompt.py _build_initial"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if "diff" not in existing_roles:
|
||||||
|
diff_body = (
|
||||||
|
"Ты — анализатор допсоглашений к договорам облачного провайдера.\n\n"
|
||||||
|
"У тебя есть текущая спецификация услуг и текст нового допсоглашения (ДС).\n"
|
||||||
|
"Твоя задача — определить, какие изменения вносит ДС в текущую спецификацию.\n\n"
|
||||||
|
"Верни СТРОГО JSON без пояснений. Не используй markdown-блоки.\n\n"
|
||||||
|
"ФОРМАТ:\n"
|
||||||
|
'{\n "mode": "partial" | "full_replace",\n "ops": [\n'
|
||||||
|
' {"action": "ADD", "new_row": {"name": "...", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}, "comment": "..."},\n'
|
||||||
|
' {"action": "UPDATE", "target_id": "rN", "new_values": {"price": число}, "comment": "..."},\n'
|
||||||
|
' {"action": "DELETE", "target_id": "rN", "comment": "..."},\n'
|
||||||
|
' {"action": "UNRESOLVED", "new_values": {"name": "...", "price": число, ...}, "reason": "почему не смог сопоставить"}\n'
|
||||||
|
" ]\n}\n\n"
|
||||||
|
"ПРАВИЛА:\n"
|
||||||
|
'1. mode = "full_replace" — если в тексте есть фразы: «в следующей редакции», «заменить приложение», «излагается в следующей редакции». При full_replace — опиши ВСЕ новые строки как ADD.\n'
|
||||||
|
'2. mode = "partial" — если ДС меняет только отдельные строки.\n'
|
||||||
|
"3. Для UPDATE/DELETE — укажи target_id (r1, r2...) из списка текущей спецификации. НЕ придумывай новые id.\n"
|
||||||
|
"4. new_values в UPDATE — только ИЗМЕНЁННЫЕ поля (не все).\n"
|
||||||
|
"5. Если не можешь однозначно сопоставить строку — action: UNRESOLVED с reason.\n"
|
||||||
|
"6. Пропускай итоговые строки и подписи.\n"
|
||||||
|
"7. price, qty, sum — ЧИСЛА (не строки).\n\n"
|
||||||
|
"ТЕКУЩАЯ СПЕЦИФИКАЦИЯ:\n{spec_current}\n\n"
|
||||||
|
"ТЕКСТ ДОПСОГЛАШЕНИЯ:\n---\n{doc_text}\n---"
|
||||||
|
)
|
||||||
|
execute(
|
||||||
|
"INSERT INTO prompts (id, role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s, %s)",
|
||||||
|
(str(uuid.uuid4()), "diff", "default-v1", diff_body, True, "Авто-создан из llm_prompt.py _build_diff"),
|
||||||
|
)
|
||||||
|
_ensure_classify_prompt()
|
||||||
|
|
||||||
|
|
||||||
|
def list_by_role(role):
|
||||||
|
"""List all versions for a role, newest first."""
|
||||||
|
rows = query(
|
||||||
|
"SELECT id, role, name, is_active, notes, created_at FROM prompts WHERE role=%s ORDER BY created_at DESC",
|
||||||
|
(role,),
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
if r.get("created_at"):
|
||||||
|
r["created_at"] = str(r["created_at"])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def save_new_version(role, name, body, notes="", is_active=True):
|
||||||
|
"""Save new prompt version. Deactivates all others for this role, inserts new one."""
|
||||||
|
if is_active:
|
||||||
|
execute("UPDATE prompts SET is_active=false WHERE role=%s", (role,))
|
||||||
|
pid = str(uuid.uuid4())
|
||||||
|
execute(
|
||||||
|
"""INSERT INTO prompts (id, role, name, body, is_active, notes)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||||
|
(pid, role, name, body, is_active, notes),
|
||||||
|
)
|
||||||
|
return get(pid)
|
||||||
|
|
||||||
|
|
||||||
|
def activate(prompt_id):
|
||||||
|
"""Activate a prompt version (deactivates others for same role)."""
|
||||||
|
row = query("SELECT role FROM prompts WHERE id=%s", (prompt_id,))
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
role = row[0]["role"]
|
||||||
|
execute("UPDATE prompts SET is_active=false WHERE role=%s", (role,))
|
||||||
|
execute("UPDATE prompts SET is_active=true WHERE id=%s", (prompt_id,))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete_prompt(prompt_id):
|
||||||
|
"""Delete a prompt version (cannot delete active one)."""
|
||||||
|
row = query("SELECT is_active FROM prompts WHERE id=%s", (prompt_id,))
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
if row[0]["is_active"]:
|
||||||
|
return False # cannot delete active
|
||||||
|
execute("DELETE FROM prompts WHERE id=%s", (prompt_id,))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_classify_prompt():
|
||||||
|
"""Ensure classify prompt exists (idempotent)."""
|
||||||
|
existing = query("SELECT id FROM prompts WHERE role = 'classify' AND is_active = true LIMIT 1")
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
body = (
|
||||||
|
"Ты — система классификации договорных документов. "
|
||||||
|
"Проанализируй текст и верни СТРОГО ВАЛИДНЫЙ JSON ОДНОЙ СТРОКОЙ "
|
||||||
|
"(без переносов строк, без markdown, без лишних пробелов в начале/конце).\n\n"
|
||||||
|
"Поля:\n"
|
||||||
|
'- doc_type: "contract" (договор) / "supplement" (допсоглашение) / "specification" (спецификация/приложение) / "other"\n'
|
||||||
|
"- own_number: номер ЭТОГО документа, строка без лишних пробелов (или null)\n"
|
||||||
|
'- parent_number: номер родительского договора из фразы «к Договору №...» (или null)\n'
|
||||||
|
'- doc_date: дата в YYYY-MM-DD. «27 февраля 2026» → 2026-02-27 (или null)\n'
|
||||||
|
"- counterparty: полное название контрагента (Заказчик/Арендатор), без сокращений (или null)\n\n"
|
||||||
|
"Пример вывода:\n"
|
||||||
|
'{"doc_type":"supplement","own_number":"1","parent_number":"01300_2","doc_date":"2026-02-27","counterparty":"АО XXX003"}\n\n'
|
||||||
|
"ДОКУМЕНТ:\n---\n{header_text}\n---"
|
||||||
|
)
|
||||||
|
execute(
|
||||||
|
"INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)",
|
||||||
|
("classify", "default-v1", body, True, "Авто-создан: classify prompt v2 — LLM сам предложил"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""spec_current — текущее состояние спецификации."""
|
||||||
|
from db.connection import query
|
||||||
|
|
||||||
|
|
||||||
|
def list_by_contract(contract_id):
|
||||||
|
"""Return list of dicts with name_hash, name, price, qty, sum, date_start."""
|
||||||
|
return query(
|
||||||
|
"""SELECT name_hash, name, price, qty, sum, date_start
|
||||||
|
FROM spec_current WHERE contract_id = %s ORDER BY name""",
|
||||||
|
(contract_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_elements_json(document_id):
|
||||||
|
"""Get elements_json for a document."""
|
||||||
|
rows = query(
|
||||||
|
"SELECT elements_json FROM documents WHERE id = %s", (document_id,)
|
||||||
|
)
|
||||||
|
return rows[0]["elements_json"] if rows and rows[0]["elements_json"] else None
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""spec_events — event sourcing: apply ops, reset contract."""
|
||||||
|
import json, uuid
|
||||||
|
from db.connection import query, execute, get_conn, transaction
|
||||||
|
|
||||||
|
|
||||||
|
def reset(contract_id):
|
||||||
|
"""Clear spec_events + spec_current for contract."""
|
||||||
|
execute("DELETE FROM spec_current WHERE contract_id = %s", (contract_id,))
|
||||||
|
execute("DELETE FROM spec_events WHERE contract_id = %s", (contract_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def clear_current(contract_id):
|
||||||
|
"""Очистить ТОЛЬКО текущее состояние спецификации (история spec_events сохраняется). Для full_replace."""
|
||||||
|
execute("DELETE FROM spec_current WHERE contract_id = %s", (contract_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_seq(contract_id):
|
||||||
|
"""Get next sequence number. WAL serializes writers — no explicit lock needed."""
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT seq FROM spec_events WHERE contract_id = ? ORDER BY seq DESC LIMIT 1",
|
||||||
|
(contract_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return (row["seq"] + 1) if row else 1
|
||||||
|
|
||||||
|
|
||||||
|
def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
|
||||||
|
"""Apply ADD/UPDATE/DELETE ops. Returns summary dict."""
|
||||||
|
with transaction():
|
||||||
|
return _apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
|
||||||
|
added = 0
|
||||||
|
updated = 0
|
||||||
|
deleted = 0
|
||||||
|
unresolved = 0
|
||||||
|
seq = get_next_seq(contract_id)
|
||||||
|
|
||||||
|
for op in ops:
|
||||||
|
action = op.get("action", "").upper()
|
||||||
|
|
||||||
|
if action == "ADD":
|
||||||
|
nr = op.get("new_row", {})
|
||||||
|
name = nr.get("name", "")
|
||||||
|
if not name:
|
||||||
|
# ADD without name → UNRESOLVED
|
||||||
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "ADD with empty name")
|
||||||
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
|
continue
|
||||||
|
name_hash = _hash(name, nr.get("date_start"))
|
||||||
|
execute(
|
||||||
|
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
||||||
|
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
||||||
|
VALUES (%s, %s, %s, %s, 'ADD', %s, %s, %s, 'applied', %s, %s, %s)""",
|
||||||
|
(
|
||||||
|
str(uuid.uuid4()), contract_id, supplement_id, seq, name_hash,
|
||||||
|
json.dumps(nr, ensure_ascii=False),
|
||||||
|
op.get("comment", ""), prompt_id, document_id,
|
||||||
|
json.dumps(raw_llm_response, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_upsert_spec_current(contract_id, name_hash, nr)
|
||||||
|
seq += 1
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
elif action == "UPDATE":
|
||||||
|
nv = op.get("new_values", {})
|
||||||
|
th = op.get("target_hash", "")
|
||||||
|
if not th:
|
||||||
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "UPDATE with empty target_hash")
|
||||||
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
|
continue
|
||||||
|
execute(
|
||||||
|
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
||||||
|
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
||||||
|
VALUES (%s, %s, %s, %s, 'UPDATE', %s, %s, %s, 'applied', %s, %s, %s)""",
|
||||||
|
(
|
||||||
|
str(uuid.uuid4()), contract_id, supplement_id, seq, th,
|
||||||
|
json.dumps(nv, ensure_ascii=False),
|
||||||
|
op.get("comment", ""), prompt_id, document_id,
|
||||||
|
json.dumps(raw_llm_response, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_update_spec_current(contract_id, th, nv)
|
||||||
|
seq += 1
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
elif action == "DELETE":
|
||||||
|
th = op.get("target_hash", "")
|
||||||
|
if not th:
|
||||||
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "DELETE with empty target_hash")
|
||||||
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
|
continue
|
||||||
|
execute(
|
||||||
|
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
||||||
|
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
||||||
|
VALUES (%s, %s, %s, %s, 'DELETE', %s, %s, %s, 'applied', %s, %s, %s)""",
|
||||||
|
(
|
||||||
|
str(uuid.uuid4()), contract_id, supplement_id, seq, th,
|
||||||
|
json.dumps({}), op.get("comment", ""),
|
||||||
|
prompt_id, document_id,
|
||||||
|
json.dumps(raw_llm_response, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
execute("DELETE FROM spec_current WHERE contract_id = %s AND name_hash = %s", (contract_id, th))
|
||||||
|
seq += 1
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
elif action == "UNRESOLVED":
|
||||||
|
# Log but don't apply
|
||||||
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
|
||||||
|
op.get("reason", op.get("comment", "")))
|
||||||
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Unknown action — log as UNRESOLVED
|
||||||
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
|
||||||
|
f"unknown action: {action}")
|
||||||
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
|
|
||||||
|
return {"added": added, "updated": updated, "deleted": deleted, "unresolved": unresolved}
|
||||||
|
|
||||||
|
|
||||||
|
def _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, reason):
|
||||||
|
"""Log an op as UNRESOLVED instead of silently ignoring it."""
|
||||||
|
execute(
|
||||||
|
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
||||||
|
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
||||||
|
VALUES (%s, %s, %s, %s, 'UNRESOLVED', %s, %s, %s, 'unresolved', %s, %s, %s)""",
|
||||||
|
(
|
||||||
|
str(uuid.uuid4()), contract_id, supplement_id, seq,
|
||||||
|
op.get("target_hash", ""),
|
||||||
|
json.dumps(op.get("new_values", op.get("new_row", {})) or {}, ensure_ascii=False),
|
||||||
|
reason,
|
||||||
|
prompt_id, document_id,
|
||||||
|
json.dumps(raw_llm_response, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(name, date_start=None):
|
||||||
|
"""Нормализованный хеш услуги. Включает нормализованный date_start чтобы различать периоды."""
|
||||||
|
import hashlib
|
||||||
|
key = name.strip().lower()
|
||||||
|
if date_start:
|
||||||
|
from services.metrics import normalize_date
|
||||||
|
nd = normalize_date(str(date_start))
|
||||||
|
if nd:
|
||||||
|
key += "|" + nd
|
||||||
|
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_spec_current(contract_id, name_hash, row):
|
||||||
|
"""INSERT or UPDATE spec_current."""
|
||||||
|
existing = query(
|
||||||
|
"SELECT id FROM spec_current WHERE contract_id = %s AND name_hash = %s",
|
||||||
|
(contract_id, name_hash),
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
execute(
|
||||||
|
"""UPDATE spec_current SET name=%s, price=%s, qty=%s, sum=%s, date_start=%s, updated_at=datetime('now')
|
||||||
|
WHERE contract_id=%s AND name_hash=%s""",
|
||||||
|
(row.get("name"), row.get("price"), row.get("qty"), row.get("sum"),
|
||||||
|
row.get("date_start"), contract_id, name_hash),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
execute(
|
||||||
|
"""INSERT INTO spec_current (id, contract_id, name_hash, name, price, qty, sum, date_start)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||||
|
(str(uuid.uuid4()), contract_id, name_hash, row.get("name"), row.get("price"),
|
||||||
|
row.get("qty"), row.get("sum"), row.get("date_start")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_spec_current(contract_id, name_hash, new_values):
|
||||||
|
"""Update specific fields in spec_current."""
|
||||||
|
sets = []
|
||||||
|
params = []
|
||||||
|
for field in ("name", "price", "qty", "sum", "date_start"):
|
||||||
|
if field in new_values:
|
||||||
|
sets.append(f"{field} = %s")
|
||||||
|
params.append(new_values[field])
|
||||||
|
if sets:
|
||||||
|
if "name" in new_values or "date_start" in new_values:
|
||||||
|
updated_name = new_values.get("name")
|
||||||
|
updated_date = new_values.get("date_start")
|
||||||
|
if updated_name is None or updated_date is None:
|
||||||
|
current = query(
|
||||||
|
"SELECT name, date_start FROM spec_current WHERE contract_id = %s AND name_hash = %s",
|
||||||
|
(contract_id, name_hash),
|
||||||
|
)
|
||||||
|
if current:
|
||||||
|
updated_name = updated_name if updated_name is not None else current[0]["name"]
|
||||||
|
updated_date = updated_date if updated_date is not None else current[0]["date_start"]
|
||||||
|
if updated_name is not None:
|
||||||
|
sets.append("name_hash = %s")
|
||||||
|
params.append(_hash(updated_name, updated_date))
|
||||||
|
sets.append("updated_at = datetime('now')")
|
||||||
|
params.extend([contract_id, name_hash])
|
||||||
|
execute(
|
||||||
|
f"UPDATE spec_current SET {', '.join(sets)} WHERE contract_id = %s AND name_hash = %s",
|
||||||
|
params,
|
||||||
|
)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Supplements CRUD."""
|
||||||
|
import uuid
|
||||||
|
from db.connection import query, execute
|
||||||
|
|
||||||
|
|
||||||
|
def insert(contract_id, document_id, supp_type="additional"):
|
||||||
|
sid = str(uuid.uuid4())
|
||||||
|
execute(
|
||||||
|
"""INSERT INTO supplements (id, contract_id, document_id, type)
|
||||||
|
VALUES (%s, %s, %s, %s)""",
|
||||||
|
(sid, contract_id, document_id, supp_type),
|
||||||
|
)
|
||||||
|
return get(sid)
|
||||||
|
|
||||||
|
|
||||||
|
def list_by_contract(contract_id):
|
||||||
|
"""Supplements with parsed documents, ordered by created_at."""
|
||||||
|
return query(
|
||||||
|
"""SELECT s.id, s.type, s.document_id, d.filename
|
||||||
|
FROM supplements s
|
||||||
|
JOIN documents d ON s.document_id = d.id
|
||||||
|
WHERE s.contract_id = %s AND d.elements_json IS NOT NULL
|
||||||
|
ORDER BY s.created_at""",
|
||||||
|
(contract_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get(supp_id):
|
||||||
|
rows = query("SELECT * FROM supplements WHERE id = %s", (supp_id,))
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_by_document(contract_id, filename):
|
||||||
|
"""Delete ALL supplements+documents by contract+filename (cascade: spec_events first).
|
||||||
|
Handles duplicates from previously failed uploads."""
|
||||||
|
rows = query(
|
||||||
|
"""SELECT s.id as sid, s.document_id FROM supplements s
|
||||||
|
JOIN documents d ON d.id = s.document_id
|
||||||
|
WHERE s.contract_id = %s AND d.filename = %s""",
|
||||||
|
(contract_id, filename),
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
# 1. Delete spec_current rows referencing this supplement's events
|
||||||
|
execute(
|
||||||
|
"""DELETE FROM spec_current WHERE contract_id = %s
|
||||||
|
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
|
||||||
|
(contract_id, r["sid"]),
|
||||||
|
)
|
||||||
|
# 2. Delete spec_events referencing this supplement
|
||||||
|
execute("DELETE FROM spec_events WHERE supplement_id = %s", (r["sid"],))
|
||||||
|
# 3. Delete supplement
|
||||||
|
execute("DELETE FROM supplements WHERE id = %s", (r["sid"],))
|
||||||
|
# 4. Delete document
|
||||||
|
execute("DELETE FROM documents WHERE id = %s", (r["document_id"],))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete(supp_id):
|
||||||
|
return execute("DELETE FROM supplements WHERE id = %s", (supp_id,))
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""llm_prompt.py — Формирование промпта для LLM-анализа ДС.
|
||||||
|
Читает активный промпт из БД напрямую (db.prompts). При ошибке — fallback на хардкод."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from db import prompts as db_prompts
|
||||||
|
|
||||||
|
# ── Fallback-промпты (если БД недоступна) ──────────────────────
|
||||||
|
|
||||||
|
FALLBACK_EXTRACT = """Ты — анализатор договоров облачного провайдера и ЦОД (дата-центра).
|
||||||
|
Ты разбираешь спецификации услуг colocation, аренды стоек, питания, каналов связи и облачных ресурсов.
|
||||||
|
|
||||||
|
ЗАДАЧА: ниже текст спецификации услуг из ПЕРВОГО документа (базовый договор).
|
||||||
|
Извлеки ВСЕ строки спецификации, каждую как отдельную ADD-операцию.
|
||||||
|
|
||||||
|
Верни СТРОГО JSON без пояснений. Не используй markdown-блоки, не добавляй текст до или после JSON.
|
||||||
|
|
||||||
|
ФОРМАТ:
|
||||||
|
{{
|
||||||
|
"mode": "partial",
|
||||||
|
"ops": [
|
||||||
|
{{"action": "ADD", "new_row": {{"name": "полное наименование", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}}, "comment": ""}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
ДОМЕННЫЙ ГЛОССАРИЙ (для корректного разбора):
|
||||||
|
- Единицы измерения:
|
||||||
|
\u2022 кВт — мощность электропитания (номинальная/гарантированная).
|
||||||
|
\u2022 юнит, U — высота места в стойке (1U, 2U, 10U).
|
||||||
|
\u2022 шт. — счётные позиции (IP-адреса, кросс-соединения, порты).
|
||||||
|
\u2022 Мбит/с, Гбит/с — пропускная способность канала связи.
|
||||||
|
\u2022 ГБ, ТБ — объём диска/хранилища; vCPU — виртуальные ядра; RAM ГБ — память.
|
||||||
|
- Типичные услуги:
|
||||||
|
\u2022 «Стойко-место» / «Аренда стойко-места» / «Colocation» — размещение оборудования в стойке ЦОД.
|
||||||
|
\u2022 «Электропитание» / «Питание» — выделенная мощность в кВт.
|
||||||
|
\u2022 «IP-адрес» (IPv4/IPv6) — считается в шт.
|
||||||
|
\u2022 «Канал связи» / «Порт» / «Интернет» — пропускная способность.
|
||||||
|
\u2022 «Кросс-соединение» (cross-connect) — физическая коммутация, шт.
|
||||||
|
\u2022 «Облачные ресурсы» — vCPU, RAM, диск.
|
||||||
|
- Мощность и габариты часто входят В СОСТАВ названия услуги:
|
||||||
|
«Аренда стойко-места, в составе: Номинальная мощность – 10 кВт». Сохраняй такое название ЦЕЛИКОМ.
|
||||||
|
|
||||||
|
ПРАВИЛА:
|
||||||
|
1. Извлеки КАЖДУЮ строку таблицы спецификации как отдельную ADD-операцию.
|
||||||
|
2. name — полное наименование услуги дословно, со всеми уточнениями (мощность, объём, кол-во в составе). Не сокращай.
|
||||||
|
3. Пропускай итоговые строки («Итого», «Всего», «НДС», «К оплате») и строки с подписями/реквизитами.
|
||||||
|
4. Если ячейка пустая или значение не указано — ставь null (НЕ пиши 0).
|
||||||
|
5. price, qty, sum — ЧИСЛА (без пробелов, без «руб.», точка как десятичный разделитель). «50 000,00 руб.» \u2192 50000.
|
||||||
|
6. date_start — дата начала оказания услуги в формате YYYY-MM-DD. Если в документе нет — null.
|
||||||
|
7. Не вычисляй и не «исправляй» суммы. Бери значения как в документе.
|
||||||
|
|
||||||
|
ПРИМЕР:
|
||||||
|
Текст: «1. Аренда стойко-места, в составе: Номинальная мощность – 10 кВт — 1 шт. — 50 000,00 руб. — 50 000,00 руб. Дата начала: 01.01.2025
|
||||||
|
2. IP-адрес IPv4 — 8 шт. — 300,00 руб. — 2 400,00 руб.»
|
||||||
|
Ответ:
|
||||||
|
{{
|
||||||
|
"mode": "partial",
|
||||||
|
"ops": [
|
||||||
|
{{"action": "ADD", "new_row": {{"name": "Аренда стойко-места, в составе: Номинальная мощность – 10 кВт", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2025-01-01"}}, "comment": ""}},
|
||||||
|
{{"action": "ADD", "new_row": {{"name": "IP-адрес IPv4", "price": 300, "qty": 8, "sum": 2400, "date_start": null}}, "comment": ""}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
ТЕКСТ ДОКУМЕНТА:
|
||||||
|
---
|
||||||
|
{doc_text}
|
||||||
|
---"""
|
||||||
|
|
||||||
|
FALLBACK_DIFF = """Ты — анализатор допсоглашений (ДС) к договорам облачного провайдера и ЦОД.
|
||||||
|
У тебя есть ТЕКУЩАЯ спецификация услуг (с готовыми id строк) и текст нового ДС.
|
||||||
|
Задача — определить, какие изменения ДС вносит в текущую спецификацию.
|
||||||
|
|
||||||
|
Верни СТРОГО JSON без пояснений. Не используй markdown-блоки, не добавляй текст до или после JSON.
|
||||||
|
|
||||||
|
ФОРМАТ:
|
||||||
|
{{
|
||||||
|
"mode": "partial" | "full_replace",
|
||||||
|
"ops": [
|
||||||
|
{{"action": "ADD", "new_row": {{"name": "...", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}}, "comment": "..."}},
|
||||||
|
{{"action": "UPDATE", "target_id": "rN", "new_values": {{"price": число}}, "comment": "..."}},
|
||||||
|
{{"action": "DELETE", "target_id": "rN", "comment": "..."}},
|
||||||
|
{{"action": "UNRESOLVED", "new_values": {{"name": "...", "price": число}}, "reason": "почему не смог сопоставить"}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
ДОМЕННЫЙ ГЛОССАРИЙ:
|
||||||
|
- Единицы: кВт (мощность), юнит/U (высота в стойке), шт. (IP, кросс-соединения, порты), Мбит/с\u00b7Гбит/с (канал), ГБ\u00b7ТБ\u00b7vCPU (облако).
|
||||||
|
- Услуги: стойко-место / colocation (размещение в стойке); электропитание / питание (мощность кВт); IP-адрес IPv4/IPv6 (шт.); канал связи / порт (пропускная способность); кросс-соединение (шт.); облачные ресурсы (vCPU/RAM/диск).
|
||||||
|
- Мощность/объём часто ВНУТРИ названия услуги: «Аренда стойко-места, в составе: Номинальная мощность – 10 кВт».
|
||||||
|
Если ДС меняет мощность (10 кВт \u2192 15 кВт) — это UPDATE той же строки, причём меняется и name, и, как правило, price/sum.
|
||||||
|
|
||||||
|
СОПОСТАВЛЕНИЕ СТРОК:
|
||||||
|
1. Для UPDATE/DELETE укажи target_id (r1, r2\u2026) ИЗ списка текущей спецификации ниже. НЕ придумывай новые id.
|
||||||
|
2. Сопоставляй по СМЫСЛУ услуги, а не по точному совпадению символов. «Аренда стойко-места» = «Размещение оборудования в стойке» = одна услуга. Различие тире/пробелов/кавычек игнорируй.
|
||||||
|
3. new_values в UPDATE — ТОЛЬКО изменённые поля (не дублируй неизменные).
|
||||||
|
4. Если ДС увеличивает количество той же услуги (было 8 IP, стало 12) — это UPDATE qty (и sum), а не новая ADD.
|
||||||
|
|
||||||
|
РЕЖИМ mode:
|
||||||
|
5. mode = "full_replace" — если ДС полностью переиздаёт приложение/спецификацию. Признаки: «Приложение \u2026 излагается в следующей редакции», «изложить в новой редакции», «заменить приложение \u2116\u2026». При full_replace опиши ВСЕ строки новой редакции как ADD (UPDATE/DELETE не используй).
|
||||||
|
6. mode = "partial" — если ДС точечно меняет отдельные позиции (изменить цену, добавить/удалить услугу, изменить мощность/кол-во).
|
||||||
|
7. Если в тексте есть и фраза о новой редакции, и точечные правки — приоритет за «новой редакцией»: full_replace.
|
||||||
|
|
||||||
|
EDGE-CASES:
|
||||||
|
8. UNRESOLVED — если ДС упоминает изменение услуги, которой НЕТ в текущей спецификации, ИЛИ название настолько отличается, что нельзя уверенно сопоставить с конкретным id. ВАЖНО: если СОМНЕВАЕШЬСЯ в сопоставлении — делай UNRESOLVED, а НЕ ADD. Лучше unresolved, чем ложный дубликат. В reason укажи причину.
|
||||||
|
9. Частичные данные: если в ДС нет цены/кол-ва/даты — ставь null для этих полей, не выдумывай.
|
||||||
|
10. Пропускай итоговые строки («Итого», «НДС», «К оплате») и подписи/реквизиты.
|
||||||
|
11. price, qty, sum — ЧИСЛА (без «руб.», без пробелов; «55 000,00» \u2192 55000). Не пересчитывай суммы сам — бери из ДС.
|
||||||
|
12. date_start — YYYY-MM-DD; используй дату вступления изменения в силу из ДС, если она указана.
|
||||||
|
|
||||||
|
ПРИМЕР 1 (partial, UPDATE цены):
|
||||||
|
Текущая спецификация:
|
||||||
|
[id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01
|
||||||
|
[id: r2] IP-адрес IPv4 | цена=300 | объём=8 | сумма=2400 | начало=2025-01-01
|
||||||
|
Текст ДС: «С 01.03.2025 стоимость аренды стойко-места устанавливается в размере 55 000,00 руб. в месяц.»
|
||||||
|
Ответ:
|
||||||
|
{{
|
||||||
|
"mode": "partial",
|
||||||
|
"ops": [
|
||||||
|
{{"action": "UPDATE", "target_id": "r1", "new_values": {{"price": 55000, "sum": 55000, "date_start": "2025-03-01"}}, "comment": "Изменение стоимости аренды стойко-места"}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
ПРИМЕР 2 (partial: ADD новая услуга + UPDATE количества + UPDATE мощности):
|
||||||
|
Текущая спецификация:
|
||||||
|
[id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01
|
||||||
|
[id: r2] IP-адрес IPv4 | цена=300 | объём=8 | сумма=2400 | начало=2025-01-01
|
||||||
|
Текст ДС: «С 01.04.2025: 1) увеличить номинальную мощность стойко-места до 15 кВт, стоимость — 70 000,00 руб.;
|
||||||
|
2) предоставить дополнительно 4 IP-адреса IPv4 (итого 12 шт., сумма 3 600,00 руб.);
|
||||||
|
3) предоставить услугу "Кросс-соединение" — 2 шт. по 1 500,00 руб., сумма 3 000,00 руб.»
|
||||||
|
Ответ:
|
||||||
|
{{
|
||||||
|
"mode": "partial",
|
||||||
|
"ops": [
|
||||||
|
{{"action": "UPDATE", "target_id": "r1", "new_values": {{"name": "Аренда стойко-места, в составе: Номинальная мощность – 15 кВт", "price": 70000, "sum": 70000, "date_start": "2025-04-01"}}, "comment": "Увеличение мощности 10\u219215 кВт"}},
|
||||||
|
{{"action": "UPDATE", "target_id": "r2", "new_values": {{"qty": 12, "sum": 3600, "date_start": "2025-04-01"}}, "comment": "Увеличение количества IP-адресов 8\u219212"}},
|
||||||
|
{{"action": "ADD", "new_row": {{"name": "Кросс-соединение", "price": 1500, "qty": 2, "sum": 3000, "date_start": "2025-04-01"}}, "comment": "Новая услуга"}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
ПРИМЕР 3 (full_replace):
|
||||||
|
Текущая спецификация:
|
||||||
|
[id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01
|
||||||
|
[id: r2] IP-адрес IPv4 | цена=300 | объём=8 | сумма=2400 | начало=2025-01-01
|
||||||
|
Текст ДС: «Приложение №1 (Спецификация услуг) излагается в следующей редакции:
|
||||||
|
1. Аренда стойко-места, номинальная мощность 15 кВт — 1 шт. — 70 000,00 руб.
|
||||||
|
2. IP-адрес IPv4 — 12 шт. — 300,00 руб. — 3 600,00 руб.
|
||||||
|
3. Канал связи 1 Гбит/с — 1 шт. — 20 000,00 руб. Дата: 01.05.2025»
|
||||||
|
Ответ:
|
||||||
|
{{
|
||||||
|
"mode": "full_replace",
|
||||||
|
"ops": [
|
||||||
|
{{"action": "ADD", "new_row": {{"name": "Аренда стойко-места, номинальная мощность 15 кВт", "price": 70000, "qty": 1, "sum": 70000, "date_start": "2025-05-01"}}, "comment": "Новая редакция приложения"}},
|
||||||
|
{{"action": "ADD", "new_row": {{"name": "IP-адрес IPv4", "price": 300, "qty": 12, "sum": 3600, "date_start": "2025-05-01"}}, "comment": "Новая редакция приложения"}},
|
||||||
|
{{"action": "ADD", "new_row": {{"name": "Канал связи 1 Гбит/с", "price": 20000, "qty": 1, "sum": 20000, "date_start": "2025-05-01"}}, "comment": "Новая редакция приложения"}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
ПРИМЕР 4 (UNRESOLVED):
|
||||||
|
Текущая спецификация:
|
||||||
|
[id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01
|
||||||
|
Текст ДС: «Снизить стоимость услуги резервного копирования до 4 000,00 руб.»
|
||||||
|
Ответ:
|
||||||
|
{{
|
||||||
|
"mode": "partial",
|
||||||
|
"ops": [
|
||||||
|
{{"action": "UNRESOLVED", "new_values": {{"name": "Резервное копирование", "price": 4000}}, "reason": "В текущей спецификации нет услуги резервного копирования — не с чем сопоставить"}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
ТЕКУЩАЯ СПЕЦИФИКАЦИЯ:
|
||||||
|
{spec_current}
|
||||||
|
|
||||||
|
ТЕКСТ ДОПСОГЛАШЕНИЯ:
|
||||||
|
---
|
||||||
|
{doc_text}
|
||||||
|
---"""
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_prompt(role: str) -> dict | None:
|
||||||
|
"""Получить активный промпт из БД напрямую (а не через Lucee HTTP)."""
|
||||||
|
try:
|
||||||
|
row = db_prompts.get_active(role)
|
||||||
|
if row and row.get("body"):
|
||||||
|
return {"id": row.get("id", ""), "body": row["body"]}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_spec_text(current_spec: list) -> str:
|
||||||
|
"""Перечисление строк спецификации для подстановки в {spec_current}."""
|
||||||
|
lines = []
|
||||||
|
for i, r in enumerate(current_spec):
|
||||||
|
lines.append(
|
||||||
|
f"[id: r{i+1}] {r.get('name', '?')} | "
|
||||||
|
f"цена={r.get('price', '')} | объём={r.get('qty', '')} | "
|
||||||
|
f"сумма={r.get('sum', '')} | начало={r.get('date_start', '')}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(current_spec: list, doc_text: str) -> tuple:
|
||||||
|
"""
|
||||||
|
Формирует промпт для LLM.
|
||||||
|
1. Пробует получить активный промпт из БД (SQLite, db.prompts).
|
||||||
|
2. При неудаче — fallback на хардкод.
|
||||||
|
Возвращает (текст_промпта, prompt_id).
|
||||||
|
prompt_id — UUID версии промпта из БД, или "" если fallback.
|
||||||
|
"""
|
||||||
|
is_first = len(current_spec) == 0
|
||||||
|
role = "extract" if is_first else "diff"
|
||||||
|
|
||||||
|
db = _fetch_prompt(role)
|
||||||
|
if db:
|
||||||
|
template = db["body"]
|
||||||
|
prompt_id = db.get("id", "")
|
||||||
|
else:
|
||||||
|
template = FALLBACK_EXTRACT if is_first else FALLBACK_DIFF
|
||||||
|
prompt_id = ""
|
||||||
|
|
||||||
|
# Подстановка плейсхолдеров
|
||||||
|
result = template.replace("{doc_text}", doc_text)
|
||||||
|
result = result.replace("{spec_current}", _build_spec_text(current_spec))
|
||||||
|
|
||||||
|
return result, prompt_id
|
||||||
|
|
||||||
|
|
||||||
|
def build_classify_prompt(header_text):
|
||||||
|
"""Build classify prompt. Returns (prompt, prompt_id)."""
|
||||||
|
try:
|
||||||
|
from db import prompts as db_prompts
|
||||||
|
prompt = db_prompts.get_active("classify")
|
||||||
|
if prompt:
|
||||||
|
body = prompt["body"].replace("{header_text}", header_text)
|
||||||
|
return body, prompt.get("id", "")
|
||||||
|
except Exception:
|
||||||
|
pass # DB unavailable — use fallback
|
||||||
|
body = """Ты — классификатор договорных документов облачного провайдера НУБЕС.
|
||||||
|
|
||||||
|
Ниже фрагмент текста документа. Определи:
|
||||||
|
|
||||||
|
1. doc_type:
|
||||||
|
- "contract" — договор (заголовок «Договор», «Соглашение», преамбула с условиями)
|
||||||
|
- "supplement" — допсоглашение (ссылается на родительский договор, меняет условия)
|
||||||
|
- "specification" — спецификация / приложение с таблицей услуг (стойко-места, IP, каналы, питание)
|
||||||
|
- "other" — НЕ договорной документ: акт сверки, счёт, счёт-фактура, УПД, акт оказанных услуг, платёжное поручение, доверенность, письмо
|
||||||
|
|
||||||
|
2. own_number — номер ЭТОГО документа (например «XXX001-03700», «МЭС-123/2024», «1» для допника).
|
||||||
|
Если номер не указан — null.
|
||||||
|
|
||||||
|
3. parent_number — номер родительского договора (для supplement и specification).
|
||||||
|
Для doc_type="contract": ВСЕГДА null.
|
||||||
|
|
||||||
|
4. doc_date — дата документа в формате YYYY-MM-DD. Если дата прописью — переведи в цифры.
|
||||||
|
Если нет даты — null.
|
||||||
|
|
||||||
|
5. counterparty — название КОНТРАГЕНТА (Заказчика).
|
||||||
|
ВАЖНО: НУБЕС — всегда Исполнитель. НЕ возвращай НУБЕС как counterparty.
|
||||||
|
НУБЕС известен как: «НУБЕС», «ООО НУБЕС», «ООО "НУБЕС"», «Nubes».
|
||||||
|
counterparty — ВСЕГДА другая сторона (Заказчик/Покупатель/Абонент).
|
||||||
|
Если документ не содержит контрагента — null.
|
||||||
|
|
||||||
|
Верни СТРОГО JSON без пояснений:
|
||||||
|
{"doc_type":"...","own_number":"...","parent_number":"...","doc_date":"...","counterparty":"..."}
|
||||||
|
|
||||||
|
ПРИМЕР 1 (договор):
|
||||||
|
Текст: «Договор № XXX001-03700 от 15.03.2025. ООО "НУБЕС" (Исполнитель) и ЗАО "ТехноПлюс" (Заказчик)...»
|
||||||
|
Ответ: {"doc_type":"contract","own_number":"XXX001-03700","parent_number":null,"doc_date":"2025-03-15","counterparty":"ЗАО \"ТехноПлюс\""}
|
||||||
|
|
||||||
|
ПРИМЕР 2 (допсоглашение):
|
||||||
|
Текст: «Допсоглашение №1 к Договору № XXX003-01300 от 05.06.2024...»
|
||||||
|
Ответ: {"doc_type":"supplement","own_number":"1","parent_number":"XXX003-01300","doc_date":"2024-06-05","counterparty":"АО XXX003"}
|
||||||
|
|
||||||
|
ПРИМЕР 3 (мусор):
|
||||||
|
Текст: «Акт сверки взаимных расчётов за 1 квартал 2025 г. Стороны: НУБЕС и ООО Ромашка. Сальдо 150 000 руб.»
|
||||||
|
Ответ: {"doc_type":"other","own_number":null,"parent_number":null,"doc_date":"2025-03-31","counterparty":"ООО Ромашка"}
|
||||||
|
|
||||||
|
ДОКУМЕНТ:
|
||||||
|
---
|
||||||
|
{header_text}
|
||||||
|
---""".replace("{header_text}", header_text)
|
||||||
|
return body, ""
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""Repository facade — Protocol поверх db/*.py.
|
||||||
|
|
||||||
|
План decoupling Ф3:
|
||||||
|
- Repository (Protocol) — интерфейс доступа к данным
|
||||||
|
- PgRepository — реальная БД (обёртка над db/*.py)
|
||||||
|
- MemRepository — in-memory для юнит-тестов
|
||||||
|
- SQL не переписываем
|
||||||
|
"""
|
||||||
|
from typing import Protocol, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
# ── Протокол ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Repository(Protocol):
|
||||||
|
"""Фасад доступа к данным."""
|
||||||
|
|
||||||
|
def insert_document(self, filename: str, mime_type: str, original_bytes: str,
|
||||||
|
batch_id: str = None, zip_source: str = None) -> dict:
|
||||||
|
"""Вставить документ, вернуть row dict."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_document_parsed(self, doc_id: str, elements: list) -> None:
|
||||||
|
"""Обновить elements_json + status='parsed'."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_document_error(self, doc_id: str, error: str) -> None:
|
||||||
|
"""Обновить status='error'."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_classification(self, doc_id: str, doc_type: str, own_number: str = None,
|
||||||
|
parent_number: str = None, doc_date: str = None,
|
||||||
|
counterparty: str = None,
|
||||||
|
classify_raw: str = None, classify_input: str = None) -> None:
|
||||||
|
"""Сохранить результат классификации."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_classify_garbage(self, doc_id: str, reason: str = "") -> None:
|
||||||
|
"""Пометить документ как мусор."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_classify_failed(self, doc_id: str, error: str) -> None:
|
||||||
|
"""Пометить классификацию как failed."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_pending(self, batch_id: str) -> list[dict]:
|
||||||
|
"""Документы, ожидающие классификации."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def insert_contract(self, number: str, client: str = "") -> str:
|
||||||
|
"""Создать контракт, вернуть contract_id."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def insert_supplement(self, contract_id: str, doc_id: str, supp_type: str) -> None:
|
||||||
|
"""Создать связь contract↔document."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_supplements(self, contract_id: str) -> list[dict]:
|
||||||
|
"""Список дополнений контракта."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_spec_current(self, contract_id: str) -> list[dict]:
|
||||||
|
"""Текущая спецификация контракта."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_document(self, doc_id: str) -> dict | None:
|
||||||
|
"""Получить документ по id."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_by_batch(self, batch_id: str) -> list[dict]:
|
||||||
|
"""Все документы батча с полями классификации."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def count_by_status(self, batch_id: str) -> dict[str, int]:
|
||||||
|
"""Количество документов по classify_status."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def reset_classify_status(self, batch_id: str) -> None:
|
||||||
|
"""Сбросить classify_status на 'pending'."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_classify_processing(self, doc_id: str) -> None:
|
||||||
|
"""Пометить документ как обрабатываемый."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def delete_document(self, doc_id: str) -> None:
|
||||||
|
"""Удалить документ."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# ── Продакшен: обёртка над db/*.py ──────────────────────────────
|
||||||
|
|
||||||
|
class PgRepository:
|
||||||
|
"""Реальный доступ к PostgreSQL через существующие db/*.py."""
|
||||||
|
|
||||||
|
def insert_document(self, filename, mime_type, original_bytes, batch_id=None, zip_source=None):
|
||||||
|
from db import documents
|
||||||
|
return documents.insert(filename, mime_type, original_bytes,
|
||||||
|
batch_id=batch_id, zip_source=zip_source)
|
||||||
|
|
||||||
|
def set_document_parsed(self, doc_id, elements):
|
||||||
|
from db import documents
|
||||||
|
documents.set_parsed(doc_id, elements) # documents.set_parsed уже делает json.dumps
|
||||||
|
|
||||||
|
def set_document_error(self, doc_id, error):
|
||||||
|
from db import documents
|
||||||
|
documents.set_error(doc_id, error)
|
||||||
|
|
||||||
|
def set_classification(self, doc_id, doc_type, own_number=None, parent_number=None,
|
||||||
|
doc_date=None, counterparty=None,
|
||||||
|
classify_raw=None, classify_input=None):
|
||||||
|
from db import documents
|
||||||
|
documents.set_classification(doc_id, doc_type, own_number, parent_number,
|
||||||
|
doc_date, counterparty,
|
||||||
|
classify_raw=classify_raw, classify_input=classify_input)
|
||||||
|
|
||||||
|
def set_classify_garbage(self, doc_id, reason=""):
|
||||||
|
from db import documents
|
||||||
|
documents.set_classify_garbage(doc_id, reason)
|
||||||
|
|
||||||
|
def set_classify_failed(self, doc_id, error):
|
||||||
|
from db import documents
|
||||||
|
documents.set_classify_failed(doc_id, error)
|
||||||
|
|
||||||
|
def list_pending(self, batch_id):
|
||||||
|
from db import documents
|
||||||
|
return documents.list_pending(batch_id)
|
||||||
|
|
||||||
|
def insert_contract(self, number, client=""):
|
||||||
|
from db import contracts
|
||||||
|
c = contracts.insert(number, client)
|
||||||
|
return c["id"] if c else ""
|
||||||
|
|
||||||
|
def insert_supplement(self, contract_id, doc_id, supp_type):
|
||||||
|
from db import supplements
|
||||||
|
supplements.insert(contract_id, doc_id, supp_type)
|
||||||
|
|
||||||
|
def list_supplements(self, contract_id):
|
||||||
|
from db import supplements
|
||||||
|
return supplements.list_by_contract(contract_id)
|
||||||
|
|
||||||
|
def get_spec_current(self, contract_id):
|
||||||
|
from db import spec_current
|
||||||
|
return spec_current.list_by_contract(contract_id)
|
||||||
|
|
||||||
|
def get_document(self, doc_id):
|
||||||
|
from db import documents
|
||||||
|
return documents.get(doc_id)
|
||||||
|
|
||||||
|
def list_by_batch(self, batch_id):
|
||||||
|
from db import documents
|
||||||
|
return documents.list_by_batch(batch_id)
|
||||||
|
|
||||||
|
def count_by_status(self, batch_id):
|
||||||
|
from db import documents
|
||||||
|
return documents.count_by_status(batch_id)
|
||||||
|
|
||||||
|
def reset_classify_status(self, batch_id):
|
||||||
|
from db import documents
|
||||||
|
documents.reset_classify_status(batch_id)
|
||||||
|
|
||||||
|
def set_classify_processing(self, doc_id):
|
||||||
|
from db import documents
|
||||||
|
documents.set_classify_processing(doc_id)
|
||||||
|
|
||||||
|
def delete_document(self, doc_id):
|
||||||
|
from db import documents
|
||||||
|
documents.delete(doc_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Тестовый: in-memory заглушка ────────────────────────────────
|
||||||
|
|
||||||
|
class MemRepository:
|
||||||
|
"""In-memory хранилище для юнит-тестов."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.documents: dict[str, dict] = {}
|
||||||
|
self.contracts: dict[str, dict] = {}
|
||||||
|
self.supplements: list[dict] = []
|
||||||
|
self.spec_current: dict[str, list[dict]] = {}
|
||||||
|
|
||||||
|
def insert_document(self, filename, mime_type, original_bytes, batch_id=None, zip_source=None):
|
||||||
|
doc_id = str(uuid.uuid4())
|
||||||
|
self.documents[doc_id] = {
|
||||||
|
"id": doc_id, "filename": filename, "mime_type": mime_type,
|
||||||
|
"original_bytes": original_bytes, "status": "uploaded",
|
||||||
|
"elements_json": None, "doc_type": None, "own_number": None,
|
||||||
|
"parent_number": None, "doc_date": None, "counterparty": None,
|
||||||
|
"classify_status": "pending", "batch_id": batch_id, "zip_source": zip_source,
|
||||||
|
}
|
||||||
|
return self.documents[doc_id]
|
||||||
|
|
||||||
|
def set_document_parsed(self, doc_id, elements):
|
||||||
|
if doc_id in self.documents:
|
||||||
|
self.documents[doc_id]["elements_json"] = elements
|
||||||
|
self.documents[doc_id]["status"] = "parsed"
|
||||||
|
|
||||||
|
def set_document_error(self, doc_id, error):
|
||||||
|
if doc_id in self.documents:
|
||||||
|
self.documents[doc_id]["status"] = "error"
|
||||||
|
self.documents[doc_id]["error_message"] = error
|
||||||
|
|
||||||
|
def set_classification(self, doc_id, doc_type, own_number=None, parent_number=None,
|
||||||
|
doc_date=None, counterparty=None,
|
||||||
|
classify_raw=None, classify_input=None):
|
||||||
|
if doc_id in self.documents:
|
||||||
|
d = self.documents[doc_id]
|
||||||
|
d.update({"doc_type": doc_type, "own_number": own_number,
|
||||||
|
"parent_number": parent_number, "doc_date": doc_date,
|
||||||
|
"counterparty": counterparty, "classify_raw": classify_raw,
|
||||||
|
"classify_input": classify_input, "classify_status": "classified"})
|
||||||
|
|
||||||
|
def set_classify_garbage(self, doc_id, reason=""):
|
||||||
|
if doc_id in self.documents:
|
||||||
|
self.documents[doc_id]["doc_type"] = "garbage"
|
||||||
|
self.documents[doc_id]["classify_status"] = "garbage"
|
||||||
|
|
||||||
|
def set_classify_failed(self, doc_id, error):
|
||||||
|
if doc_id in self.documents:
|
||||||
|
self.documents[doc_id]["classify_status"] = "failed"
|
||||||
|
self.documents[doc_id]["error_message"] = error
|
||||||
|
|
||||||
|
def list_pending(self, batch_id):
|
||||||
|
return [d for d in self.documents.values()
|
||||||
|
if d.get("batch_id") == batch_id and d.get("classify_status") == "pending"]
|
||||||
|
|
||||||
|
def insert_contract(self, number, client=""):
|
||||||
|
cid = str(uuid.uuid4())
|
||||||
|
self.contracts[cid] = {"id": cid, "number": number, "client": client}
|
||||||
|
return cid
|
||||||
|
|
||||||
|
def insert_supplement(self, contract_id, doc_id, supp_type):
|
||||||
|
self.supplements.append({
|
||||||
|
"contract_id": contract_id, "document_id": doc_id, "type": supp_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
def list_supplements(self, contract_id):
|
||||||
|
return [s for s in self.supplements if s["contract_id"] == contract_id]
|
||||||
|
|
||||||
|
def get_spec_current(self, contract_id):
|
||||||
|
return self.spec_current.get(contract_id, [])
|
||||||
|
|
||||||
|
def get_document(self, doc_id):
|
||||||
|
return self.documents.get(doc_id)
|
||||||
|
|
||||||
|
def list_by_batch(self, batch_id):
|
||||||
|
return [d for d in self.documents.values() if d.get("batch_id") == batch_id]
|
||||||
|
|
||||||
|
def count_by_status(self, batch_id):
|
||||||
|
counts = {}
|
||||||
|
for d in self.documents.values():
|
||||||
|
if d.get("batch_id") == batch_id:
|
||||||
|
s = d.get("classify_status", "unknown")
|
||||||
|
counts[s] = counts.get(s, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
def reset_classify_status(self, batch_id):
|
||||||
|
for d in self.documents.values():
|
||||||
|
if d.get("batch_id") == batch_id:
|
||||||
|
d["classify_status"] = "pending"
|
||||||
|
|
||||||
|
def set_classify_processing(self, doc_id):
|
||||||
|
if doc_id in self.documents:
|
||||||
|
self.documents[doc_id]["classify_status"] = "processing"
|
||||||
|
|
||||||
|
def delete_document(self, doc_id):
|
||||||
|
self.documents.pop(doc_id, None)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Регистрация всех blueprint'ов."""
|
||||||
|
|
||||||
|
|
||||||
|
def register_routes(app):
|
||||||
|
import config
|
||||||
|
from routes.upload_bp import contracts_upload_sink
|
||||||
|
from routes.pipeline_bp import pipeline_bp
|
||||||
|
from routes.api_bp import api_bp
|
||||||
|
from routes.prompts_bp import prompts_bp
|
||||||
|
from routes.health_bp import health_bp
|
||||||
|
from routes.pages_bp import pages_bp
|
||||||
|
from upload.backend.upload_refs import create_upload_refs_blueprint
|
||||||
|
|
||||||
|
app.register_blueprint(pipeline_bp)
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
app.register_blueprint(prompts_bp)
|
||||||
|
app.register_blueprint(health_bp)
|
||||||
|
app.register_blueprint(pages_bp)
|
||||||
|
|
||||||
|
# Переиспользуемый слой закачки через ВМ (модуль upload, паттерн drhider)
|
||||||
|
app.register_blueprint(create_upload_refs_blueprint({
|
||||||
|
"apiPrefix": "/api",
|
||||||
|
"vmUploadPrefix": config.VM_UPLOAD_PREFIX,
|
||||||
|
"maxFileBytes": config.VM_UPLOAD_MAX_BYTES,
|
||||||
|
"pullRetries": config.PULL_RETRIES,
|
||||||
|
"pullRetryDelay": config.PULL_RETRY_DELAY,
|
||||||
|
}, sink=contracts_upload_sink))
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""API blueprint — groups, documents, supplements, sync, cleanup, spec-current, chat."""
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from db import documents, supplements, spec_current
|
||||||
|
from db.connection import execute, query
|
||||||
|
from services.grouping import group_documents, apply_groups
|
||||||
|
from config import LLM_URL, LLM_KEY, LLM_MODEL
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
api_bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Supplements ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@api_bp.route("/api/supplements")
|
||||||
|
def api_supplements():
|
||||||
|
cid = request.args.get("contract_id")
|
||||||
|
if not cid:
|
||||||
|
return jsonify(ok=False, error="contract_id required"), 400
|
||||||
|
rows = supplements.list_by_contract(cid)
|
||||||
|
return jsonify(ok=True, supplements=rows)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Documents ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@api_bp.route("/api/documents/<doc_id>")
|
||||||
|
def api_document(doc_id):
|
||||||
|
doc = documents.get(doc_id)
|
||||||
|
if not doc:
|
||||||
|
return jsonify(ok=False, error="not found"), 404
|
||||||
|
return jsonify(ok=True, **{
|
||||||
|
k: doc.get(k) for k in [
|
||||||
|
"id", "filename", "status", "elements_json", "doc_type",
|
||||||
|
"own_number", "parent_number", "doc_date", "counterparty",
|
||||||
|
"classify_status", "classify_raw", "classify_input",
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/api/documents/<doc_id>", methods=["DELETE"])
|
||||||
|
def api_document_delete(doc_id):
|
||||||
|
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (doc_id,))
|
||||||
|
for s in (supps or []):
|
||||||
|
execute(
|
||||||
|
"""DELETE FROM spec_current WHERE contract_id = %s
|
||||||
|
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
|
||||||
|
(s["contract_id"], s["id"]),
|
||||||
|
)
|
||||||
|
execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
|
||||||
|
execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
|
||||||
|
execute("DELETE FROM documents WHERE id = %s", (doc_id,))
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sync ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@api_bp.route("/api/sync", methods=["POST"])
|
||||||
|
def api_sync():
|
||||||
|
"""Удалить документы, не входящие в keep_ids."""
|
||||||
|
body = request.get_json()
|
||||||
|
keep_ids = set(body.get("keep_ids", []))
|
||||||
|
if len(keep_ids) > 1000:
|
||||||
|
return jsonify(ok=False, error="too many keep_ids (max 1000)"), 400
|
||||||
|
|
||||||
|
docs = query("SELECT id FROM documents", ())
|
||||||
|
deleted = 0
|
||||||
|
for d in (docs or []):
|
||||||
|
if d["id"] in keep_ids:
|
||||||
|
continue
|
||||||
|
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (d["id"],))
|
||||||
|
for s in (supps or []):
|
||||||
|
execute(
|
||||||
|
"""DELETE FROM spec_current WHERE contract_id = %s
|
||||||
|
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
|
||||||
|
(s["contract_id"], s["id"]),
|
||||||
|
)
|
||||||
|
execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
|
||||||
|
execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
|
||||||
|
execute("DELETE FROM documents WHERE id = %s", (d["id"],))
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
execute("DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)")
|
||||||
|
return jsonify(ok=True, deleted=deleted)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Groups ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@api_bp.route("/api/groups")
|
||||||
|
def api_groups():
|
||||||
|
batch_id = request.args.get("batch")
|
||||||
|
if not batch_id:
|
||||||
|
return jsonify(ok=False, error="batch required"), 400
|
||||||
|
result = group_documents(batch_id)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/api/batch-progress")
|
||||||
|
def api_batch_progress():
|
||||||
|
batch_id = request.args.get("batch")
|
||||||
|
if not batch_id:
|
||||||
|
return jsonify(ok=False, error="batch required"), 400
|
||||||
|
counts = documents.count_by_status(batch_id)
|
||||||
|
docs = documents.list_by_batch(batch_id)
|
||||||
|
return jsonify(ok=True, counts=counts, total=len(docs))
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/api/apply-groups", methods=["POST"])
|
||||||
|
def api_apply_groups():
|
||||||
|
body = request.get_json()
|
||||||
|
batch_id = body.get("batch_id")
|
||||||
|
groups = body.get("groups", [])
|
||||||
|
if not batch_id:
|
||||||
|
return jsonify(ok=False, error="batch_id required"), 400
|
||||||
|
result = apply_groups(batch_id, groups)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Spec current ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@api_bp.route("/api/spec-current")
|
||||||
|
def api_spec_current():
|
||||||
|
cid = request.args.get("contract_id")
|
||||||
|
if not cid:
|
||||||
|
return jsonify(ok=False, error="contract_id required"), 400
|
||||||
|
rows = spec_current.list_by_contract(cid)
|
||||||
|
return jsonify(ok=True, rows=rows)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Chat (совместимость с Lucee /chat.cfm) ──────────────────────
|
||||||
|
|
||||||
|
@api_bp.route("/chat", methods=["POST"])
|
||||||
|
@api_bp.route("/chat.cfm", methods=["POST"])
|
||||||
|
def chat():
|
||||||
|
"""Чат с LLM по данным спецификации. Совместим с Lucee-форматом ответа."""
|
||||||
|
contract_id = request.args.get("contract_id", "")
|
||||||
|
question = request.form.get("question", "")
|
||||||
|
if not contract_id or not question:
|
||||||
|
return jsonify(OK=False, ERROR="contract_id and question required")
|
||||||
|
|
||||||
|
rows = spec_current.list_by_contract(contract_id)
|
||||||
|
if not rows:
|
||||||
|
return jsonify(OK=True, ANSWER="Нет данных. Сначала запустите сравнение.")
|
||||||
|
|
||||||
|
ctx = "\n".join(
|
||||||
|
f"{r.get('name','')} | цена={r.get('price')} | объём={r.get('qty')} | "
|
||||||
|
f"сумма={r.get('sum')} | начало={r.get('date_start')}"
|
||||||
|
for r in rows
|
||||||
|
)
|
||||||
|
prompt = (
|
||||||
|
f"Ты — анализатор договоров. Данные:\n{ctx}\n\n"
|
||||||
|
f"Вопрос: {question}\nОтветь кратко, только по данным."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=120) as client:
|
||||||
|
resp = client.post(
|
||||||
|
LLM_URL,
|
||||||
|
json={
|
||||||
|
"model": LLM_MODEL,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": 1000,
|
||||||
|
"temperature": 0.1,
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {LLM_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
answer = resp.json()["choices"][0]["message"]["content"]
|
||||||
|
return jsonify(OK=True, ANSWER=answer)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify(OK=False, ERROR=f"LLM error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cleanup (атомарное удаление БД) ──────────────────────────────
|
||||||
|
|
||||||
|
@api_bp.route("/api/cleanup", methods=["POST"])
|
||||||
|
def api_cleanup():
|
||||||
|
"""Полная очистка: os.remove(DB) + init новой. Данные гарантированно стёрты."""
|
||||||
|
from db.connection import cleanup_db
|
||||||
|
cleanup_db()
|
||||||
|
return jsonify(ok=True, message="all data cleaned")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Health probe — обязательно для Штурвала."""
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
from config import VERSION
|
||||||
|
|
||||||
|
health_bp = Blueprint("health", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@health_bp.route("/health")
|
||||||
|
def health():
|
||||||
|
return jsonify({"ok": True, "version": VERSION})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""HTML-страницы."""
|
||||||
|
import os
|
||||||
|
from flask import Blueprint, render_template, send_from_directory
|
||||||
|
|
||||||
|
pages_bp = Blueprint("pages", __name__)
|
||||||
|
|
||||||
|
# Переиспользуемый модуль upload: отдаём ES-модули фронтенда браузеру (как drhider)
|
||||||
|
_UPLOAD_FRONTEND = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"upload", "frontend",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pages_bp.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@pages_bp.route("/architect")
|
||||||
|
def architect():
|
||||||
|
return render_template("architect.html")
|
||||||
|
|
||||||
|
|
||||||
|
@pages_bp.route("/upload/<path:filename>")
|
||||||
|
def upload_frontend(filename):
|
||||||
|
"""Отдать статику ES-модулей upload/frontend (для клиентского ZIP)."""
|
||||||
|
return send_from_directory(_UPLOAD_FRONTEND, filename)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Pipeline blueprint — SSE-сравнение + classify."""
|
||||||
|
import json, re, os, threading
|
||||||
|
from flask import Blueprint, request, jsonify, Response, stream_with_context
|
||||||
|
from services.process import run_pipeline
|
||||||
|
from services.classify import classify_batch
|
||||||
|
from llm_prompt import build_prompt
|
||||||
|
from db import documents
|
||||||
|
|
||||||
|
pipeline_bp = Blueprint("pipeline", __name__)
|
||||||
|
|
||||||
|
# In-memory lock для classify (замена файлового lock)
|
||||||
|
_classify_locks: dict[str, threading.Thread] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@pipeline_bp.route("/process-v2", methods=["GET"])
|
||||||
|
def process_v2():
|
||||||
|
"""SSE-стриминг сравнения договоров."""
|
||||||
|
cid = request.args.get("contract_id")
|
||||||
|
if not cid or not re.fullmatch(
|
||||||
|
r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', cid, re.I
|
||||||
|
):
|
||||||
|
return jsonify(ok=False, error="invalid contract_id"), 400
|
||||||
|
|
||||||
|
order_ids = request.args.get("order", "")
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
# Heartbeat каждые 15 сек — держит соединение при медленном LLM
|
||||||
|
import time as _time
|
||||||
|
last_beat = _time.time()
|
||||||
|
|
||||||
|
yield ": ok\n\n"
|
||||||
|
try:
|
||||||
|
for event in run_pipeline(cid, order_ids, build_prompt):
|
||||||
|
now = _time.time()
|
||||||
|
if now - last_beat >= 15:
|
||||||
|
yield ": heartbeat\n\n"
|
||||||
|
last_beat = now
|
||||||
|
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||||
|
except GeneratorExit:
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
stream_with_context(generate()),
|
||||||
|
content_type="text/event-stream; charset=utf-8",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pipeline_bp.route("/api/classify-batch", methods=["POST"])
|
||||||
|
def classify_batch_route():
|
||||||
|
"""Запустить классификацию. ≤10 файлов — sync, >10 — async (Thread)."""
|
||||||
|
body = request.get_json()
|
||||||
|
batch_id = body.get("batch_id")
|
||||||
|
if not batch_id:
|
||||||
|
return jsonify(ok=False, error="batch_id required"), 400
|
||||||
|
|
||||||
|
# Guard: уже запущена?
|
||||||
|
if batch_id in _classify_locks:
|
||||||
|
t = _classify_locks[batch_id]
|
||||||
|
if t.is_alive():
|
||||||
|
return jsonify(ok=False, error="classify already running"), 409
|
||||||
|
else:
|
||||||
|
del _classify_locks[batch_id]
|
||||||
|
|
||||||
|
pending = documents.list_pending(batch_id)
|
||||||
|
total = len(pending)
|
||||||
|
if total == 0:
|
||||||
|
return jsonify(ok=False, error="no pending documents"), 400
|
||||||
|
|
||||||
|
# Sync для малых батчей
|
||||||
|
if total <= 10:
|
||||||
|
result = classify_batch(batch_id)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
# Async для больших
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
classify_batch(batch_id)
|
||||||
|
finally:
|
||||||
|
_classify_locks.pop(batch_id, None)
|
||||||
|
|
||||||
|
t = threading.Thread(target=_run, daemon=True)
|
||||||
|
_classify_locks[batch_id] = t
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
return jsonify(ok=True, total=total), 202
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Prompts blueprint — CRUD + activate для версионирования промптов."""
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from db import prompts as db_prompts
|
||||||
|
|
||||||
|
prompts_bp = Blueprint("prompts", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@prompts_bp.route("/api/prompts", methods=["GET"])
|
||||||
|
def prompts_get():
|
||||||
|
role = request.args.get("role")
|
||||||
|
if not role:
|
||||||
|
return jsonify(ok=False, error="role required"), 400
|
||||||
|
p = db_prompts.get_active(role)
|
||||||
|
if p:
|
||||||
|
return jsonify(ok=True, **{
|
||||||
|
"id": p["id"], "role": p["role"], "name": p["name"],
|
||||||
|
"body": p["body"], "is_active": p["is_active"],
|
||||||
|
"notes": p.get("notes"), "created_at": str(p.get("created_at", "")),
|
||||||
|
})
|
||||||
|
return jsonify(ok=False, error="not found"), 404
|
||||||
|
|
||||||
|
|
||||||
|
@prompts_bp.route("/api/prompts/list", methods=["GET"])
|
||||||
|
def prompts_list():
|
||||||
|
role = request.args.get("role")
|
||||||
|
if not role:
|
||||||
|
return jsonify(ok=False, error="role required"), 400
|
||||||
|
versions = db_prompts.list_by_role(role)
|
||||||
|
return jsonify(ok=True, versions=versions)
|
||||||
|
|
||||||
|
|
||||||
|
@prompts_bp.route("/api/prompts/save", methods=["POST"])
|
||||||
|
def prompts_save():
|
||||||
|
body = request.get_json()
|
||||||
|
if not body:
|
||||||
|
return jsonify(ok=False, error="body required"), 400
|
||||||
|
role = body.get("role", "")
|
||||||
|
name = body.get("name", "")
|
||||||
|
prompt_body = body.get("body", "")
|
||||||
|
notes = body.get("notes", "")
|
||||||
|
try:
|
||||||
|
p = db_prompts.save_new_version(role, name, prompt_body, notes)
|
||||||
|
return jsonify(ok=True, id=p["id"])
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify(ok=False, error=str(e)), 500
|
||||||
|
|
||||||
|
|
||||||
|
@prompts_bp.route("/api/prompts/activate", methods=["POST"])
|
||||||
|
def prompts_activate():
|
||||||
|
body = request.get_json()
|
||||||
|
if not body:
|
||||||
|
return jsonify(ok=False, error="body required"), 400
|
||||||
|
prompt_id = body.get("id")
|
||||||
|
try:
|
||||||
|
db_prompts.activate(prompt_id)
|
||||||
|
return jsonify(ok=True)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify(ok=False, error=str(e)), 500
|
||||||
|
|
||||||
|
|
||||||
|
@prompts_bp.route("/api/prompts/delete", methods=["POST"])
|
||||||
|
def prompts_delete():
|
||||||
|
body = request.get_json()
|
||||||
|
if not body:
|
||||||
|
return jsonify(ok=False, error="body required"), 400
|
||||||
|
prompt_id = body.get("id")
|
||||||
|
try:
|
||||||
|
db_prompts.delete_prompt(prompt_id)
|
||||||
|
return jsonify(ok=True)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify(ok=False, error=str(e)), 500
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Upload-модуль: конвертация .doc→.docx + хранение/парсинг (sink для VM-буфера)."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from services.parse import parse_file
|
||||||
|
from db import documents
|
||||||
|
import config
|
||||||
|
|
||||||
|
|
||||||
|
def _convert(filename: str, data: bytes) -> bytes:
|
||||||
|
""".doc → .docx через внешний libreoffice-сервис. Возвращает docx-байты."""
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
config.CONVERT_SERVICE_URL + "/convert",
|
||||||
|
files={"file": (filename, data, "application/msword")},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise Exception("conversion timeout")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
try:
|
||||||
|
err = resp.json().get("error", "conversion failed")
|
||||||
|
except Exception:
|
||||||
|
err = "conversion failed"
|
||||||
|
raise Exception(err)
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
def _store_and_parse(filename: str, data: bytes, batch_id, contract_id, zip_source=None, mime_type="application/octet-stream"):
|
||||||
|
"""Общая логика: дедуп → insert в БД → авто-парсинг. Возвращает dict-результат."""
|
||||||
|
content_hash = hashlib.sha256(data).hexdigest()[:16]
|
||||||
|
|
||||||
|
# Дедупликация по хешу
|
||||||
|
if batch_id:
|
||||||
|
existing = documents.get_by_hash(batch_id, content_hash)
|
||||||
|
if existing:
|
||||||
|
return {"ok": False, "error": "duplicate", "doc_id": existing["id"], "duplicate_of": True}
|
||||||
|
|
||||||
|
doc = documents.insert(
|
||||||
|
filename=filename,
|
||||||
|
mime_type=mime_type,
|
||||||
|
original_bytes=base64.b64encode(data).decode(),
|
||||||
|
batch_id=batch_id,
|
||||||
|
zip_source=zip_source,
|
||||||
|
content_hash=content_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Авто-парсинг
|
||||||
|
try:
|
||||||
|
result = parse_file(filename, data)
|
||||||
|
if result["status"] == "parsed":
|
||||||
|
documents.set_parsed(doc["id"], result["elements"])
|
||||||
|
parsed = {"status": "parsed", "element_count": result.get("element_count", 0)}
|
||||||
|
else:
|
||||||
|
documents.set_error(doc["id"], result.get("error", "parse failed"))
|
||||||
|
parsed = {"status": "error", "error": result.get("error", "parse failed")}
|
||||||
|
except Exception as e:
|
||||||
|
documents.set_error(doc["id"], str(e))
|
||||||
|
parsed = {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
return {"ok": True, "doc_id": doc["id"], "contract_id": contract_id, "parsed": parsed}
|
||||||
|
|
||||||
|
|
||||||
|
def contracts_upload_sink(name, content, batch_id=None, contract_id=None, zip_source=None):
|
||||||
|
"""Sink для переиспользуемого модуля upload: вставить файл в documents + авто-парсинг.
|
||||||
|
|
||||||
|
Вызывается модулем create_upload_refs_blueprint(cfg, sink=...) на каждый
|
||||||
|
вытянутый с ВМ файл. .doc конвертируется в .docx через внешний LibreOffice
|
||||||
|
(парсер умеет только .docx). Возвращает dict ok/doc_id/contract_id/parsed.
|
||||||
|
"""
|
||||||
|
if name.lower().endswith(".doc"):
|
||||||
|
content = _convert(name, content)
|
||||||
|
name = name[:-4] + ".docx"
|
||||||
|
return _store_and_parse(name, content, batch_id, contract_id, zip_source)
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
Classify service — LLM-based document classification.
|
||||||
|
|
||||||
|
Архитектурное решение (Opus):
|
||||||
|
- Отдельный сервис, не встроен в upload. Upload быстрый (0.5с), classify — медленный (2-10с/файл).
|
||||||
|
- ThreadPoolExecutor(max_workers=4) — параллельная классификация с ограничением конкурентности,
|
||||||
|
чтобы не положить api.aillm.ru при 2000 файлах.
|
||||||
|
- Умная выжимка (_smart_extract): header ~1500 симв + regex-хиты по маркерам (договор/№/соглашение)
|
||||||
|
из всего документа. Экономия токенов в 5-10 раз при сохранении точности.
|
||||||
|
- Двухпроходная архитектура: LLM извлекает строки (тип/номер/дата/контрагент),
|
||||||
|
Python в grouping.py нормализует и группирует детерминированно.
|
||||||
|
"""
|
||||||
|
import json, re
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from db import documents as db_docs
|
||||||
|
from llm_prompt import build_classify_prompt
|
||||||
|
|
||||||
|
log = __import__("logging").getLogger(__name__)
|
||||||
|
|
||||||
|
# Лимит одновременных запросов к LLM API
|
||||||
|
# Увеличивать осторожно — api.aillm.ru может троттлить
|
||||||
|
MAX_WORKERS = 4
|
||||||
|
|
||||||
|
from config import LLM_URL, LLM_KEY, LLM_MODEL
|
||||||
|
|
||||||
|
# Ленивый singleton — обратная совместимость
|
||||||
|
_classify_llm = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_classify_client():
|
||||||
|
global _classify_llm
|
||||||
|
if _classify_llm is None:
|
||||||
|
from services.llm_client import HttpxLLMClient
|
||||||
|
_classify_llm = HttpxLLMClient(url=LLM_URL, key=LLM_KEY, model=LLM_MODEL, max_tokens=1000, timeout=60)
|
||||||
|
return _classify_llm
|
||||||
|
|
||||||
|
# ── Garbage filter (Stage 1: filename regex) ────────────────────
|
||||||
|
_GARBAGE_FILENAME_RE = re.compile(
|
||||||
|
r'(сч[её]т|акт|плат[её]ж|УПД|сверк|инвойс|invoice|payment|act)',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Garbage filter (Stage 2: header keywords) ───────────────────
|
||||||
|
_GARBAGE_HEADER_MARKERS = [
|
||||||
|
'СЧЕТ-ФАКТУРА', 'СЧЕТ НА ОПЛАТУ', 'АКТ СВЕРКИ',
|
||||||
|
'АКТ ОКАЗАННЫХ УСЛУГ', 'АКТ ВЫПОЛНЕННЫХ РАБОТ',
|
||||||
|
'ПЛАТЁЖНОЕ ПОРУЧЕНИЕ', 'УНИВЕРСАЛЬНЫЙ ПЕРЕДАТОЧНЫЙ',
|
||||||
|
'ПЛАТЕЖНОЕ ПОРУЧЕНИЕ',
|
||||||
|
]
|
||||||
|
|
||||||
|
_GARBAGE_HEADER_RE = re.compile(
|
||||||
|
r'(?m)^\s*(?:УПД|УНИВЕРСАЛЬНЫЙ ПЕРЕДАТОЧНЫЙ ДОКУМЕНТ)\b'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_garbage_by_filename(filename: str) -> bool:
|
||||||
|
"""Stage 1: regex по имени файла — быстро, 0 токенов."""
|
||||||
|
return bool(_GARBAGE_FILENAME_RE.search(filename))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_garbage_by_header(text: str) -> bool:
|
||||||
|
"""Stage 2: ключевые слова в первых 2KB текста — быстро, 0 токенов."""
|
||||||
|
header = text[:2000].upper()
|
||||||
|
return any(marker in header for marker in _GARBAGE_HEADER_MARKERS) or bool(_GARBAGE_HEADER_RE.search(header))
|
||||||
|
|
||||||
|
|
||||||
|
def _call_llm_classify(header_text, llm_client=None):
|
||||||
|
"""
|
||||||
|
Прямой вызов LLM для классификации ОДНОГО документа.
|
||||||
|
Возвращает (parsed_dict, raw_text, needed_fix).
|
||||||
|
llm_client: LLMClient (optional). Default — HttpxLLMClient.
|
||||||
|
"""
|
||||||
|
if llm_client is None:
|
||||||
|
llm_client = _get_classify_client()
|
||||||
|
|
||||||
|
prompt, _ = build_classify_prompt(header_text)
|
||||||
|
raw_text = llm_client.complete(prompt)
|
||||||
|
parsed, needed_fix = _safe_json_parse(raw_text)
|
||||||
|
return parsed, raw_text, needed_fix
|
||||||
|
|
||||||
|
|
||||||
|
def classify_batch(batch_id, llm_client=None, repo=None):
|
||||||
|
"""
|
||||||
|
Классифицировать все документы в batch.
|
||||||
|
Сбрасывает статус на 'pending' для всех перед началом.
|
||||||
|
Параллельно (ThreadPoolExecutor) обрабатывает до MAX_WORKERS документов.
|
||||||
|
Возвращает {ok, total, done, failed, garbage, json_fix_rate}.
|
||||||
|
llm_client: LLMClient (optional, default — HttpxLLMClient)
|
||||||
|
repo: Repository (optional, default — direct db.* calls)
|
||||||
|
"""
|
||||||
|
_db = repo if repo else db_docs
|
||||||
|
_llm = llm_client if llm_client else _get_classify_client()
|
||||||
|
|
||||||
|
# Сбросить 'processing' -> 'pending' (crash recovery); уже классифицированные не трогаем
|
||||||
|
_db.reset_classify_status(batch_id)
|
||||||
|
pending = _db.list_pending(batch_id)
|
||||||
|
if not pending:
|
||||||
|
return {"ok": False, "error": "no pending documents"}
|
||||||
|
|
||||||
|
total = len(pending)
|
||||||
|
done = 0
|
||||||
|
failed = 0
|
||||||
|
garbage = 0
|
||||||
|
json_fixes = 0
|
||||||
|
json_total = 0
|
||||||
|
type_counts = {} # doc_type → count for batch summary
|
||||||
|
|
||||||
|
def _classify_one(doc):
|
||||||
|
"""Классифицировать один документ: фильтр → выжимка → LLM → сохранить."""
|
||||||
|
nonlocal garbage, json_fixes, json_total, type_counts
|
||||||
|
try:
|
||||||
|
# Stage 1: garbage by filename (0 tokens)
|
||||||
|
if _is_garbage_by_filename(doc["filename"]):
|
||||||
|
_db.set_classify_garbage(doc["id"], "filename_regex")
|
||||||
|
garbage += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Stage 2: garbage by header keywords (0 tokens)
|
||||||
|
text = _smart_extract(doc["elements_json"])
|
||||||
|
if _is_garbage_by_header(text):
|
||||||
|
_db.set_classify_garbage(doc["id"], "header_keywords")
|
||||||
|
garbage += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Stage 3: LLM classification (only for remaining)
|
||||||
|
_db.set_classify_processing(doc["id"]) # crash recovery marker
|
||||||
|
result, raw, needed_fix = _call_llm_classify(text, _llm)
|
||||||
|
json_total += 1
|
||||||
|
if needed_fix:
|
||||||
|
json_fixes += 1
|
||||||
|
dtype = result.get("doc_type", "other")
|
||||||
|
type_counts[dtype] = type_counts.get(dtype, 0) + 1
|
||||||
|
_db.set_classification(
|
||||||
|
doc["id"],
|
||||||
|
result.get("doc_type", "other"),
|
||||||
|
result.get("own_number"),
|
||||||
|
result.get("parent_number"),
|
||||||
|
result.get("doc_date"),
|
||||||
|
result.get("counterparty"),
|
||||||
|
classify_raw=raw,
|
||||||
|
classify_input=text,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
_db.set_classify_failed(doc["id"], str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
|
||||||
|
futures = {pool.submit(_classify_one, d): d for d in pending}
|
||||||
|
for f in as_completed(futures):
|
||||||
|
if f.result():
|
||||||
|
done += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return {"ok": True, "total": total, "done": done, "failed": failed,
|
||||||
|
"garbage": garbage, "json_fix_rate": round(json_fixes / max(json_total, 1), 3),
|
||||||
|
"types": type_counts, "summary": f"{total} total, {done} classified, {failed} failed, {garbage} garbage"}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json_parse(raw):
|
||||||
|
"""Parse LLM response, fixing common JSON errors.
|
||||||
|
Returns (parsed_dict, needed_fix: bool)."""
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("empty LLM response")
|
||||||
|
|
||||||
|
text = raw.strip()
|
||||||
|
# Strip markdown
|
||||||
|
if "```json" in text:
|
||||||
|
text = text.split("```json")[1].split("```")[0].strip()
|
||||||
|
elif "```" in text:
|
||||||
|
text = text.split("```")[1].split("```")[0].strip()
|
||||||
|
|
||||||
|
# Remove non-JSON prefix/suffix (LLM chatter)
|
||||||
|
brace_start = text.find("{")
|
||||||
|
brace_end = text.rfind("}")
|
||||||
|
if brace_start >= 0 and brace_end > brace_start:
|
||||||
|
text = text[brace_start:brace_end + 1]
|
||||||
|
|
||||||
|
# Try strict parse
|
||||||
|
try:
|
||||||
|
return json.loads(text), False
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
import re as _re
|
||||||
|
# Collapse multiline
|
||||||
|
text = _re.sub(r"\n\s*", " ", text)
|
||||||
|
# Remove trailing commas
|
||||||
|
text = _re.sub(r",\s*}", "}", text)
|
||||||
|
text = _re.sub(r",\s*]", "]", text)
|
||||||
|
|
||||||
|
# Try again
|
||||||
|
try:
|
||||||
|
return json.loads(text), True
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Aggressive: try adding missing closing quotes/braces
|
||||||
|
text = text.rstrip()
|
||||||
|
if not text.endswith("}"):
|
||||||
|
# Count unclosed quotes
|
||||||
|
in_string = False
|
||||||
|
for i, ch in enumerate(text):
|
||||||
|
if ch == '"' and (i == 0 or text[i-1] != "\\"):
|
||||||
|
in_string = not in_string
|
||||||
|
if in_string:
|
||||||
|
text += '"'
|
||||||
|
text += "}"
|
||||||
|
|
||||||
|
return json.loads(text), True
|
||||||
|
|
||||||
|
|
||||||
|
def _smart_extract(elements_json):
|
||||||
|
"""
|
||||||
|
Умная выжимка текста для классификации (решение Q3 от Opus).
|
||||||
|
|
||||||
|
Вместо отправки всего документа (дорого) или только header (теряет зарытые номера),
|
||||||
|
используется гибрид:
|
||||||
|
1. Первые ~1500 симв (титул, преамбула, стороны)
|
||||||
|
2. Regex-хиты по маркерам «договор|№|соглашение|приложение|спецификация»
|
||||||
|
из ВСЕГО документа
|
||||||
|
3. Дедупликация, лимит 10 строк, склейка → ~3000 симв на вход LLM
|
||||||
|
|
||||||
|
Это покрывает и титульную зону, и зарытые ссылки в середине документа.
|
||||||
|
"""
|
||||||
|
if not elements_json:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if isinstance(elements_json, str):
|
||||||
|
try:
|
||||||
|
elements = json.loads(elements_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return elements_json[:2000]
|
||||||
|
elif isinstance(elements_json, list):
|
||||||
|
elements = elements_json
|
||||||
|
else:
|
||||||
|
return str(elements_json)[:2000]
|
||||||
|
|
||||||
|
# Build full text
|
||||||
|
lines = []
|
||||||
|
for el in elements:
|
||||||
|
if isinstance(el, dict):
|
||||||
|
t = el.get("type") or el.get("TYPE", "")
|
||||||
|
if t == "paragraph":
|
||||||
|
txt = el.get("text") or el.get("TEXT", "")
|
||||||
|
if txt:
|
||||||
|
lines.append(txt)
|
||||||
|
elif t == "table":
|
||||||
|
rows = el.get("rows") or el.get("ROWS", [])
|
||||||
|
for row in rows:
|
||||||
|
lines.append(" | ".join(str(c) for c in row))
|
||||||
|
|
||||||
|
full_text = "\n".join(lines)
|
||||||
|
|
||||||
|
# Header: first ~1500 chars
|
||||||
|
header = full_text[:1500]
|
||||||
|
|
||||||
|
# Marker lines: grep for key patterns
|
||||||
|
markers = re.findall(
|
||||||
|
r'.{0,200}(?:договор|№|соглашен|приложен|специф|контрагент|заказчик|арендатор).{0,200}',
|
||||||
|
full_text, re.IGNORECASE,
|
||||||
|
)
|
||||||
|
unique_markers = list(dict.fromkeys(markers))[:10]
|
||||||
|
|
||||||
|
combined = header + "\n---\n" + "\n".join(unique_markers)
|
||||||
|
return combined[:3000]
|
||||||
@@ -207,6 +207,8 @@ class TwoPassObfuscator:
|
|||||||
"""
|
"""
|
||||||
# --- Распаковать ZIP-файлы ---
|
# --- Распаковать ZIP-файлы ---
|
||||||
files = self._expand_zips(files)
|
files = self._expand_zips(files)
|
||||||
|
# --- Конвертировать PDF → DOCX ---
|
||||||
|
files = self._convert_pdfs_to_docx(files)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# --- Проход 1: сбор сущностей ---
|
# --- Проход 1: сбор сущностей ---
|
||||||
@@ -236,10 +238,6 @@ class TwoPassObfuscator:
|
|||||||
pass
|
pass
|
||||||
elif fname in all_docx:
|
elif fname in all_docx:
|
||||||
obf_content = self._replace_in_docx(all_docx[fname])
|
obf_content = self._replace_in_docx(all_docx[fname])
|
||||||
elif fname.endswith('.pdf'):
|
|
||||||
txt = all_texts.get(fname, '')
|
|
||||||
obf_content = self._replace_in_text(txt, fname)
|
|
||||||
fname = fname[:-4] + '.txt'
|
|
||||||
else:
|
else:
|
||||||
txt = all_texts.get(fname, '')
|
txt = all_texts.get(fname, '')
|
||||||
obf_content = self._replace_in_text(txt, fname)
|
obf_content = self._replace_in_text(txt, fname)
|
||||||
@@ -253,6 +251,49 @@ class TwoPassObfuscator:
|
|||||||
self._regex_replacements.clear()
|
self._regex_replacements.clear()
|
||||||
self._sorted_keys.clear()
|
self._sorted_keys.clear()
|
||||||
|
|
||||||
|
def _convert_pdfs_to_docx(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
|
||||||
|
"""Конвертировать PDF в DOCX через pdfplumber. При совпадении имён — _из_pdf."""
|
||||||
|
import pdfplumber
|
||||||
|
from docx import Document as DocxDocument
|
||||||
|
result = []
|
||||||
|
existing_names = {f[0] for f in files}
|
||||||
|
for fname, content, ctype in files:
|
||||||
|
if not fname.lower().endswith('.pdf'):
|
||||||
|
result.append((fname, content, ctype))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
doc = DocxDocument()
|
||||||
|
with pdfplumber.open(io.BytesIO(content)) as pdf:
|
||||||
|
for page in pdf.pages:
|
||||||
|
tables = page.extract_tables()
|
||||||
|
for table in tables:
|
||||||
|
if table:
|
||||||
|
rows = [[str(c or "").strip() for c in (row or [])] for row in table]
|
||||||
|
rows = [r for r in rows if any(r)]
|
||||||
|
if rows:
|
||||||
|
t = doc.add_table(rows=len(rows), cols=len(rows[0]))
|
||||||
|
t.style = 'Table Grid'
|
||||||
|
for ri, row in enumerate(rows):
|
||||||
|
for ci, cell_text in enumerate(row):
|
||||||
|
t.rows[ri].cells[ci].text = cell_text
|
||||||
|
text = page.extract_text()
|
||||||
|
if text:
|
||||||
|
for line in text.split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
doc.add_paragraph(line)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
doc.save(buf)
|
||||||
|
new_name = fname[:-4] + '.docx'
|
||||||
|
if new_name in existing_names:
|
||||||
|
new_name = fname[:-4] + '_из_pdf.docx'
|
||||||
|
existing_names.add(new_name)
|
||||||
|
result.append((new_name, buf.getvalue(), ctype))
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("PDF→DOCX error for %s: %s", fname, e)
|
||||||
|
result.append((fname, content, ctype))
|
||||||
|
return result
|
||||||
|
|
||||||
def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
|
def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
|
||||||
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть."""
|
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть."""
|
||||||
result = []
|
result = []
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""
|
||||||
|
Grouping service — match classified documents into contract groups.
|
||||||
|
|
||||||
|
Архитектурное решение (Opus, Q4 + Q6):
|
||||||
|
- Двухпроходный гибрид: LLM извлекает строки (classify.py), Python нормализует и группирует.
|
||||||
|
- Нормализация номеров: uppercase + только буквы/цифры.
|
||||||
|
"МЭС-123-2024" == "МЭС 123/2024" после нормализации.
|
||||||
|
- Группировка на бэкенде (не на фронте): Python regex/unicode надёжнее JS.
|
||||||
|
- apply_groups(): создаёт contracts + supplements с авто-порядком по дате.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from db import documents as db_docs
|
||||||
|
from db import contracts as db_contracts
|
||||||
|
from db import supplements as db_supplements
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_number(num):
|
||||||
|
"""
|
||||||
|
Нормализация номера договора для сравнения.
|
||||||
|
Убирает всё кроме букв и цифр, приводит к uppercase.
|
||||||
|
Пример: "МЭС-123-2024" → "МЭС1232024", "МЭС 123/2024" → "МЭС1232024".
|
||||||
|
"""
|
||||||
|
if not num:
|
||||||
|
return ""
|
||||||
|
return re.sub(r"[^A-Z0-9А-Я]", "", num.upper())
|
||||||
|
|
||||||
|
|
||||||
|
def group_documents(batch_id):
|
||||||
|
"""
|
||||||
|
Сгруппировать классифицированные документы по контрактам.
|
||||||
|
|
||||||
|
Алгоритм:
|
||||||
|
1. Отделить contract от supplement/specification
|
||||||
|
2. Каждый contract → якорь группы
|
||||||
|
3. Для каждого supplement: найти contract по parent_number (нормализованный)
|
||||||
|
4. Оставшиеся supplement/spec → виртуальные группы по parent_number/own_number
|
||||||
|
5. Совсем без номеров → группа "__unresolved__"
|
||||||
|
6. Внутри группы сортировка по doc_date
|
||||||
|
|
||||||
|
Возвращает {ok, groups: [{contract_number, counterparty, documents: [...]}], total_docs}.
|
||||||
|
"""
|
||||||
|
docs = db_docs.list_by_batch(batch_id)
|
||||||
|
classified = [d for d in docs if d.get("classify_status") == "classified"]
|
||||||
|
|
||||||
|
# Separate contracts and supplements
|
||||||
|
contracts_list = []
|
||||||
|
supplements_list = []
|
||||||
|
|
||||||
|
for d in classified:
|
||||||
|
if d.get("doc_type") == "contract":
|
||||||
|
contracts_list.append(d)
|
||||||
|
else:
|
||||||
|
supplements_list.append(d)
|
||||||
|
|
||||||
|
groups = []
|
||||||
|
|
||||||
|
# Each contract becomes a group
|
||||||
|
for c in contracts_list:
|
||||||
|
group = {
|
||||||
|
"contract_number": c.get("own_number") or c.get("filename", ""),
|
||||||
|
"counterparty": c.get("counterparty") or "",
|
||||||
|
"documents": [c],
|
||||||
|
}
|
||||||
|
# Find supplements matching this contract
|
||||||
|
c_norm = normalize_number(c.get("own_number"))
|
||||||
|
for s in supplements_list:
|
||||||
|
parent = normalize_number(s.get("parent_number") or "")
|
||||||
|
own = normalize_number(s.get("own_number") or "")
|
||||||
|
if parent == c_norm or own == c_norm:
|
||||||
|
if s not in group["documents"]:
|
||||||
|
group["documents"].append(s)
|
||||||
|
|
||||||
|
# Sort by date
|
||||||
|
group["documents"].sort(key=lambda x: x.get("doc_date") or "")
|
||||||
|
|
||||||
|
# Remove matched supplements from the pool
|
||||||
|
for s in group["documents"]:
|
||||||
|
if s in supplements_list:
|
||||||
|
supplements_list.remove(s)
|
||||||
|
|
||||||
|
groups.append(group)
|
||||||
|
|
||||||
|
# ── Virtual groups: unmatched supplements grouped by number ──────────
|
||||||
|
# Group remaining supplements/specs by normalized parent_number (priority) or own_number
|
||||||
|
virtual = {}
|
||||||
|
for s in supplements_list:
|
||||||
|
num = normalize_number(s.get("parent_number") or s.get("own_number") or "")
|
||||||
|
if not num:
|
||||||
|
continue # no number → stays in supplements_list for unresolved
|
||||||
|
if num not in virtual:
|
||||||
|
# Use counterparty from first doc in group
|
||||||
|
cp = s.get("counterparty") or ""
|
||||||
|
virtual[num] = {
|
||||||
|
"contract_number": s.get("parent_number") or s.get("own_number") or "?",
|
||||||
|
"counterparty": cp,
|
||||||
|
"documents": [],
|
||||||
|
}
|
||||||
|
virtual[num]["documents"].append(s)
|
||||||
|
# Update counterparty if current doc has a better one
|
||||||
|
if not virtual[num]["counterparty"] and s.get("counterparty"):
|
||||||
|
virtual[num]["counterparty"] = s.get("counterparty")
|
||||||
|
|
||||||
|
for vnum, vgroup in virtual.items():
|
||||||
|
vgroup["documents"].sort(key=lambda x: x.get("doc_date") or "")
|
||||||
|
groups.append(vgroup)
|
||||||
|
# Remove grouped docs from supplements_list
|
||||||
|
for s in vgroup["documents"]:
|
||||||
|
supplements_list.remove(s)
|
||||||
|
|
||||||
|
# ── Unresolved: everything left (no number, failed, pending, other) ──
|
||||||
|
unmatched = [s for s in supplements_list] # remaining after virtual grouping
|
||||||
|
unmatched += [d for d in docs if d.get("classify_status") != "classified"]
|
||||||
|
|
||||||
|
if unmatched:
|
||||||
|
groups.append({
|
||||||
|
"contract_number": "__unresolved__",
|
||||||
|
"counterparty": "",
|
||||||
|
"documents": unmatched,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"ok": True, "groups": groups, "total_docs": len(docs)}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_groups(batch_id, groups_data):
|
||||||
|
"""
|
||||||
|
Применить подтверждённые группы: создать contracts + supplements.
|
||||||
|
|
||||||
|
Вызывается из POST /api/apply-groups.
|
||||||
|
Для каждой группы (кроме __unresolved__):
|
||||||
|
1. Создать запись в contracts (number, client)
|
||||||
|
2. Для каждого документа создать supplement (type='initial' для первого, 'additional' для остальных)
|
||||||
|
3. Порядок supplements соответствует порядку документов в группе (сортировка по дате уже сделана)
|
||||||
|
|
||||||
|
Возвращает {ok, created: количество созданных supplements}.
|
||||||
|
"""
|
||||||
|
created = 0
|
||||||
|
contract_ids = []
|
||||||
|
for g in groups_data:
|
||||||
|
contract_number = g.get("contract_number", "")
|
||||||
|
if contract_number == "__unresolved__":
|
||||||
|
continue
|
||||||
|
counterparty = g.get("counterparty", "")
|
||||||
|
docs = g.get("documents", [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
c = db_contracts.insert(contract_number, counterparty)
|
||||||
|
contract_id = c["id"]
|
||||||
|
contract_ids.append(contract_id)
|
||||||
|
|
||||||
|
for i, d in enumerate(docs):
|
||||||
|
doc_id = d.get("id")
|
||||||
|
if not doc_id:
|
||||||
|
continue
|
||||||
|
supp_type = "initial" if i == 0 else "additional"
|
||||||
|
db_supplements.insert(contract_id, doc_id, supp_type)
|
||||||
|
created += 1
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": f"apply_groups failed at {contract_number}: {e}"}
|
||||||
|
|
||||||
|
return {"ok": True, "created": created, "contract_ids": contract_ids}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""LLM service — call LLM API with optional DI."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from config import LLM_URL, LLM_KEY, LLM_MODEL
|
||||||
|
|
||||||
|
# Ленивый singleton — обратная совместимость
|
||||||
|
_llm_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_client():
|
||||||
|
global _llm_client
|
||||||
|
if _llm_client is None:
|
||||||
|
from services.llm_client import HttpxLLMClient
|
||||||
|
_llm_client = HttpxLLMClient(url=LLM_URL, key=LLM_KEY, model=LLM_MODEL)
|
||||||
|
return _llm_client
|
||||||
|
|
||||||
|
|
||||||
|
def call_llm(current_spec, doc_text, build_prompt_fn, llm_client=None):
|
||||||
|
"""Call LLM. Returns (parsed_result, prompt_id).
|
||||||
|
llm_client: LLMClient (optional). Default — HttpxLLMClient (prod).
|
||||||
|
"""
|
||||||
|
if llm_client is None:
|
||||||
|
llm_client = _get_default_client()
|
||||||
|
|
||||||
|
prompt, prompt_id = build_prompt_fn(current_spec, doc_text)
|
||||||
|
raw_text = llm_client.complete(prompt)
|
||||||
|
|
||||||
|
json_text = raw_text
|
||||||
|
if "```json" in json_text:
|
||||||
|
json_text = json_text.split("```json")[1].split("```")[0]
|
||||||
|
elif "```" in json_text:
|
||||||
|
json_text = json_text.split("```")[1].split("```")[0]
|
||||||
|
return json.loads(json_text.strip()), prompt_id
|
||||||
@@ -36,7 +36,7 @@ class HttpxLLMClient:
|
|||||||
"max_tokens": self.max_tokens,
|
"max_tokens": self.max_tokens,
|
||||||
"temperature": self.temperature,
|
"temperature": self.temperature,
|
||||||
}
|
}
|
||||||
with httpx.Client(http2=True, timeout=self.timeout, verify=True) as client:
|
with httpx.Client(timeout=self.timeout, verify=True) as client:
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
self.url,
|
self.url,
|
||||||
json=payload,
|
json=payload,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Metrics — quality signals without golden dataset.
|
||||||
|
|
||||||
|
Checks that work immediately:
|
||||||
|
- Arithmetic: sum == price * qty (free signal of LLM/data errors)
|
||||||
|
- JSON fix rate: how often _safe_json_parse has to repair LLM output
|
||||||
|
"""
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
|
||||||
|
def check_arithmetic(ops: list) -> list[dict]:
|
||||||
|
"""Check sum == price * qty for ADD/UPDATE operations.
|
||||||
|
Returns list of mismatches: [{action, name, price, qty, expected_sum, actual_sum, diff}]
|
||||||
|
"""
|
||||||
|
mismatches = []
|
||||||
|
for op in ops:
|
||||||
|
action = op.get("action", "")
|
||||||
|
if action not in ("ADD", "UPDATE"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
row = op.get("new_row") or op.get("new_values") or {}
|
||||||
|
price = _to_decimal(row.get("price"))
|
||||||
|
qty = _to_decimal(row.get("qty"))
|
||||||
|
actual_sum = _to_decimal(row.get("sum"))
|
||||||
|
|
||||||
|
if price is None or qty is None or actual_sum is None:
|
||||||
|
continue # can't check without all three
|
||||||
|
|
||||||
|
expected = price * qty
|
||||||
|
if expected != actual_sum:
|
||||||
|
mismatches.append({
|
||||||
|
"action": action,
|
||||||
|
"name": row.get("name", "")[:100],
|
||||||
|
"price": float(price),
|
||||||
|
"qty": float(qty),
|
||||||
|
"expected_sum": float(expected),
|
||||||
|
"actual_sum": float(actual_sum),
|
||||||
|
"diff": float(actual_sum - expected),
|
||||||
|
})
|
||||||
|
return mismatches
|
||||||
|
|
||||||
|
|
||||||
|
class ClassifyMetrics:
|
||||||
|
"""Track _safe_json_parse fix rate per batch."""
|
||||||
|
def __init__(self):
|
||||||
|
self.total = 0
|
||||||
|
self.fixes = 0 # how many times JSON needed repair
|
||||||
|
|
||||||
|
def record(self, needed_fix: bool):
|
||||||
|
self.total += 1
|
||||||
|
if needed_fix:
|
||||||
|
self.fixes += 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fix_rate(self) -> float:
|
||||||
|
return self.fixes / self.total if self.total else 0.0
|
||||||
|
|
||||||
|
def summary(self) -> dict:
|
||||||
|
return {
|
||||||
|
"total_classifications": self.total,
|
||||||
|
"json_fixes": self.fixes,
|
||||||
|
"json_fix_rate": round(self.fix_rate, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_decimal(val) -> Decimal | None:
|
||||||
|
"""Safe conversion to Decimal with number normalization."""
|
||||||
|
if val is None or val == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
s = str(val).replace("\xa0", "").replace(" ", "").replace(",", ".")
|
||||||
|
return Decimal(s)
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_date(ds: str) -> str | None:
|
||||||
|
"""Normalize date to YYYY-MM-DD. Handles DD.MM.YYYY, YYYY-MM-DD, etc."""
|
||||||
|
if not ds:
|
||||||
|
return None
|
||||||
|
import re
|
||||||
|
# DD.MM.YYYY → YYYY-MM-DD
|
||||||
|
m = re.match(r"(\d{2})\.(\d{2})\.(\d{4})", ds)
|
||||||
|
if m:
|
||||||
|
return f"{m.group(3)}-{m.group(2)}-{m.group(1)}"
|
||||||
|
# YYYY-MM-DD — already canonical
|
||||||
|
if re.match(r"\d{4}-\d{2}-\d{2}", ds):
|
||||||
|
return ds
|
||||||
|
return ds # return as-is if unrecognized format
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Parse service — PDF (pdfplumber), DOCX (python-docx), plain text fallback."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
|
||||||
|
def parse_file(filename, data):
|
||||||
|
"""Parse file bytes → {status, elements, element_count}."""
|
||||||
|
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||||
|
try:
|
||||||
|
if ext == "pdf":
|
||||||
|
return _parse_pdf(data)
|
||||||
|
elif ext == "docx":
|
||||||
|
return _parse_docx(data)
|
||||||
|
elif ext == "doc":
|
||||||
|
return _parse_docx(data) # python-docx handles most .doc
|
||||||
|
else:
|
||||||
|
return _parse_text(data)
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e), "element_count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_pdf(data):
|
||||||
|
"""Parse PDF with pdfplumber — preserves table structure (unlike PyPDF2)."""
|
||||||
|
import pdfplumber
|
||||||
|
elements = []
|
||||||
|
with pdfplumber.open(io.BytesIO(data)) as pdf:
|
||||||
|
for page in pdf.pages:
|
||||||
|
# Tables first — preserves column structure critical for specs
|
||||||
|
tables = page.extract_tables()
|
||||||
|
for table in tables:
|
||||||
|
if table:
|
||||||
|
rows = []
|
||||||
|
for row in table:
|
||||||
|
if row:
|
||||||
|
cells = [str(cell or "").strip() for cell in row]
|
||||||
|
if any(cells):
|
||||||
|
rows.append(cells)
|
||||||
|
if rows:
|
||||||
|
elements.append({"type": "table", "rows": rows})
|
||||||
|
# Remaining text as paragraphs
|
||||||
|
text = page.extract_text()
|
||||||
|
if text:
|
||||||
|
for line in text.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
elements.append({"type": "paragraph", "text": line, "style": ""})
|
||||||
|
return {"status": "parsed", "element_count": len(elements), "elements": elements}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_docx(data):
|
||||||
|
import docx
|
||||||
|
doc = docx.Document(io.BytesIO(data))
|
||||||
|
elements = []
|
||||||
|
|
||||||
|
for block in doc.element.body:
|
||||||
|
tag = block.tag.split("}")[-1] if "}" in block.tag else block.tag
|
||||||
|
if tag == "p":
|
||||||
|
text = _extract_paragraph_text(block)
|
||||||
|
if text:
|
||||||
|
elements.append({"type": "paragraph", "text": text, "style": ""})
|
||||||
|
elif tag == "tbl":
|
||||||
|
rows = []
|
||||||
|
for tr in block.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tr"):
|
||||||
|
cells = []
|
||||||
|
for tc in tr.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tc"):
|
||||||
|
cell_text = "".join(t.text or "" for t in tc.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t"))
|
||||||
|
cells.append(cell_text.strip())
|
||||||
|
if cells:
|
||||||
|
rows.append(cells)
|
||||||
|
if rows:
|
||||||
|
elements.append({"type": "table", "rows": rows})
|
||||||
|
|
||||||
|
return {"status": "parsed", "element_count": len(elements), "elements": elements}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_paragraph_text(p_elem):
|
||||||
|
texts = []
|
||||||
|
for t in p_elem.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t"):
|
||||||
|
if t.text:
|
||||||
|
texts.append(t.text)
|
||||||
|
return "".join(texts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_text(data):
|
||||||
|
text = data.decode("utf-8", errors="ignore")[:5000]
|
||||||
|
lines = [l.strip() for l in text.split("\n") if l.strip()]
|
||||||
|
return {"status": "parsed", "element_count": len(lines),
|
||||||
|
"elements": [{"type": "paragraph", "text": l, "style": ""} for l in lines]}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Process service — SSE pipeline: reset → supplements → LLM → apply.
|
||||||
|
Адаптирован из deploy/compare/process.py: callback → generator.
|
||||||
|
"""
|
||||||
|
import json, time
|
||||||
|
from db import supplements, spec_current, spec_events
|
||||||
|
from services.metrics import check_arithmetic
|
||||||
|
|
||||||
|
|
||||||
|
def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
||||||
|
"""Generator: yield SSE events вместо sse_send callback.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
for event in run_pipeline(cid, order_ids, build_prompt):
|
||||||
|
yield f"data: {json.dumps(event)}\\n\\n"
|
||||||
|
"""
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
# 0. Reset
|
||||||
|
spec_events.reset(contract_id)
|
||||||
|
|
||||||
|
# 1. Get supplements with parsed documents
|
||||||
|
supps = supplements.list_by_contract(contract_id)
|
||||||
|
if order_ids:
|
||||||
|
order_list = [x.strip() for x in order_ids.split(",") if x.strip()]
|
||||||
|
order_map = {oid: i for i, oid in enumerate(order_list)}
|
||||||
|
supps.sort(key=lambda s: order_map.get(s["id"], 999999))
|
||||||
|
else:
|
||||||
|
supps.sort(key=lambda s: (s.get("doc_date") or "9999-99-99", s.get("created_at", "")))
|
||||||
|
|
||||||
|
if not supps:
|
||||||
|
yield {"type": "error", "message": "Нет распарсенных файлов"}
|
||||||
|
return
|
||||||
|
|
||||||
|
from services.llm import call_llm
|
||||||
|
|
||||||
|
for s in supps:
|
||||||
|
sid = s["id"]
|
||||||
|
filename = s.get("filename", "?")
|
||||||
|
|
||||||
|
# Current spec
|
||||||
|
cur = spec_current.list_by_contract(contract_id)
|
||||||
|
current_spec = []
|
||||||
|
for r in cur:
|
||||||
|
current_spec.append({
|
||||||
|
"hash": r["name_hash"],
|
||||||
|
"name": r["name"],
|
||||||
|
"price": float(r["price"]) if r.get("price") is not None else None,
|
||||||
|
"qty": float(r["qty"]) if r.get("qty") is not None else None,
|
||||||
|
"sum": float(r["sum"]) if r.get("sum") is not None else None,
|
||||||
|
"date_start": r["date_start"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get elements_json
|
||||||
|
ej = spec_current.get_elements_json(s["document_id"])
|
||||||
|
if not ej:
|
||||||
|
yield {
|
||||||
|
"type": "extract_error",
|
||||||
|
"supplement_id": sid,
|
||||||
|
"filename": filename,
|
||||||
|
"error": "no elements_json",
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build doc text from elements
|
||||||
|
doc_text = _elements_to_text(ej)
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"type": "extract_start",
|
||||||
|
"supplement_id": sid,
|
||||||
|
"filename": filename,
|
||||||
|
}
|
||||||
|
|
||||||
|
# LLM call
|
||||||
|
try:
|
||||||
|
t1 = time.time()
|
||||||
|
result, prompt_id = call_llm(current_spec, doc_text, build_prompt_fn)
|
||||||
|
ops = result.get("ops", [])
|
||||||
|
mode = result.get("mode", "llm")
|
||||||
|
|
||||||
|
if mode == "full_replace" and not ops:
|
||||||
|
yield {
|
||||||
|
"type": "extract_error",
|
||||||
|
"supplement_id": sid,
|
||||||
|
"filename": filename,
|
||||||
|
"error": "full_replace returned empty ops",
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Трансляция target_id ("r1","r2"...) → target_hash (name_hash из current_spec).
|
||||||
|
# LLM возвращает target_id, а apply_ops() читает target_hash — без этого UPDATE/DELETE уходят в UNRESOLVED.
|
||||||
|
for _op in ops:
|
||||||
|
_tid = _op.get("target_id", "")
|
||||||
|
if _tid and isinstance(_tid, str) and _tid.startswith("r"):
|
||||||
|
try:
|
||||||
|
_idx = int(_tid[1:]) - 1
|
||||||
|
if 0 <= _idx < len(current_spec):
|
||||||
|
_op["target_hash"] = current_spec[_idx]["hash"]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"type": "llm_done",
|
||||||
|
"supplement_id": sid,
|
||||||
|
"filename": filename,
|
||||||
|
"ops_count": len(ops),
|
||||||
|
"mode": mode,
|
||||||
|
"time_s": round(time.time() - t1, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
# full_replace: очистить текущее состояние, чтобы ADD-строки новой редакции
|
||||||
|
# не дублировали старые (reset на старте пайплайна это не покрывает).
|
||||||
|
if mode == "full_replace":
|
||||||
|
spec_events.clear_current(contract_id)
|
||||||
|
|
||||||
|
# Apply ops to DB via apply_ops
|
||||||
|
try:
|
||||||
|
summary = spec_events.apply_ops(
|
||||||
|
contract_id, sid, s["document_id"], ops, prompt_id, result
|
||||||
|
)
|
||||||
|
applied_ops = ops
|
||||||
|
except Exception as e:
|
||||||
|
summary = {"added": 0, "updated": 0, "deleted": 0, "unresolved": len(ops)}
|
||||||
|
applied_ops = []
|
||||||
|
yield {
|
||||||
|
"type": "extract_error",
|
||||||
|
"supplement_id": sid,
|
||||||
|
"filename": filename,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Arithmetic check
|
||||||
|
arithmetic_warnings = check_arithmetic(ops)
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"type": "applied",
|
||||||
|
"supplement_id": sid,
|
||||||
|
"filename": filename,
|
||||||
|
"summary": summary,
|
||||||
|
"ops": applied_ops,
|
||||||
|
"arithmetic_warnings": arithmetic_warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
yield {
|
||||||
|
"type": "extract_error",
|
||||||
|
"supplement_id": sid,
|
||||||
|
"filename": filename,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
total_time = round(time.time() - t0, 1)
|
||||||
|
yield {"type": "done", "total_time_s": total_time}
|
||||||
|
|
||||||
|
|
||||||
|
def _elements_to_text(ej):
|
||||||
|
"""Extract flat text from elements_json for LLM prompt."""
|
||||||
|
if isinstance(ej, dict) and "Value" in ej:
|
||||||
|
ej = ej["Value"]
|
||||||
|
if isinstance(ej, str):
|
||||||
|
try:
|
||||||
|
ej = json.loads(ej)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return ej
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
if isinstance(ej, list):
|
||||||
|
for el in ej:
|
||||||
|
if isinstance(el, dict):
|
||||||
|
if el.get("type") == "paragraph":
|
||||||
|
lines.append(el.get("text", ""))
|
||||||
|
elif el.get("type") == "table":
|
||||||
|
for row in el.get("rows", []):
|
||||||
|
lines.append(" | ".join(str(c) for c in row))
|
||||||
|
elif isinstance(el, str):
|
||||||
|
lines.append(el)
|
||||||
|
return "\n".join(lines)
|
||||||
+28
-13
@@ -1,10 +1,9 @@
|
|||||||
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
|
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
|
||||||
// Contracts App — весь JS на VM (contracts.kube5s.ru)
|
// Contracts App v2.0 — всё на Flask, ВМ больше нет
|
||||||
// Lucee: только домен + index.cfm-скелет
|
var VM_API = '';
|
||||||
var VM_API = 'https://check.kube5s.ru';
|
// ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза),
|
||||||
var UPLOAD_URL = VM_API + '/upload';
|
// бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ.
|
||||||
var CONVERT_URL = VM_API + '/convert-doc';
|
var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/';
|
||||||
var UNZIP_URL = VM_API + '/unzip-upload';
|
|
||||||
// SITE_URL удалён (Фаза 4) — не использовался
|
// SITE_URL удалён (Фаза 4) — не использовался
|
||||||
|
|
||||||
var fileInput = document.getElementById('fileInput');
|
var fileInput = document.getElementById('fileInput');
|
||||||
@@ -14,11 +13,11 @@ var fileTable = document.getElementById('fileTable');
|
|||||||
// state.batchId — теперь в state.js (Фаза 0: state + render)
|
// state.batchId — теперь в state.js (Фаза 0: state + render)
|
||||||
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
|
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
|
||||||
|
|
||||||
// Автоочистка старых записей при загрузке страницы
|
// Прогрев upstream + автоочистка при загрузке страницы
|
||||||
(async function cleanup() {
|
// ⚠️ Без прогрева первый POST может упасть с ERR_CONNECTION_RESET (MTU/Geneve)
|
||||||
try {
|
(async function init() {
|
||||||
await fetch(VM_API + '/api/cleanup', { method: 'POST' });
|
try { await fetch('/health'); } catch(e) { /* ignore */ }
|
||||||
} catch(e) { /* ignore */ }
|
try { await fetch(VM_API + '/api/cleanup', { method: 'POST' }); } catch(e) { /* ignore */ }
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,6 +113,20 @@ fileInput.addEventListener('change', async function() {
|
|||||||
onFilesSelected(newFiles);
|
onFilesSelected(newFiles);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Выбор папки (webkitdirectory) — та же обработка, что и файлы
|
||||||
|
var folderInput = document.getElementById('folderInput');
|
||||||
|
var folderBtn = document.getElementById('folderBtn');
|
||||||
|
if (folderBtn && folderInput) {
|
||||||
|
folderBtn.addEventListener('click', function() { folderInput.click(); });
|
||||||
|
folderInput.addEventListener('change', function() {
|
||||||
|
var files = Array.from(folderInput.files).filter(function(f) {
|
||||||
|
var n = f.name.toLowerCase();
|
||||||
|
return n.endsWith('.pdf') || n.endsWith('.doc') || n.endsWith('.docx') || n.endsWith('.zip');
|
||||||
|
});
|
||||||
|
if (files.length) onFilesSelected(files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Классификация ──────────────────────────────────────────
|
// ── Классификация ──────────────────────────────────────────
|
||||||
function showClassifyBtn() {
|
function showClassifyBtn() {
|
||||||
var btn = document.getElementById('classifyBtn');
|
var btn = document.getElementById('classifyBtn');
|
||||||
@@ -150,7 +163,9 @@ async function runClassify() {
|
|||||||
var r = await fetch(VM_API + '/api/batch-progress?batch=' + state.batchId);
|
var r = await fetch(VM_API + '/api/batch-progress?batch=' + state.batchId);
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
if (d.ok && d.counts) {
|
if (d.ok && d.counts) {
|
||||||
var done = (d.counts.classified || 0) + (d.counts.failed || 0);
|
var done = (d.counts.classified || 0)
|
||||||
|
+ (d.counts.failed || 0)
|
||||||
|
+ (d.counts.garbage || 0);
|
||||||
var total = d.total || 0;
|
var total = d.total || 0;
|
||||||
if (total > 0) totalFiles = total;
|
if (total > 0) totalFiles = total;
|
||||||
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + done + '/' + total + ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + done + '/' + total + ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
||||||
@@ -328,7 +343,7 @@ document.getElementById('llmBtn').addEventListener('click', function() {
|
|||||||
compareStatus.textContent = '⏳ 0.0с';
|
compareStatus.textContent = '⏳ 0.0с';
|
||||||
|
|
||||||
var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
|
var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
|
||||||
var url = 'https://check.kube5s.ru/process-v2?contract_id=' + state.contractId +
|
var url = '/process-v2?contract_id=' + state.contractId +
|
||||||
(order ? '&order=' + encodeURIComponent(order) : '');
|
(order ? '&order=' + encodeURIComponent(order) : '');
|
||||||
|
|
||||||
// Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
|
// Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
|
||||||
|
|||||||
@@ -90,15 +90,15 @@ function applyCompareEvent(sections, event) {
|
|||||||
*/
|
*/
|
||||||
function renderCompareSectionHeader(sec) {
|
function renderCompareSectionHeader(sec) {
|
||||||
if (sec.status === 'extracting') {
|
if (sec.status === 'extracting') {
|
||||||
return '⏳ ' + sec.filename;
|
return '⏳ ' + escHtml(sec.filename);
|
||||||
}
|
}
|
||||||
if (sec.status === 'llm_done' || sec.status === 'applied') {
|
if (sec.status === 'llm_done' || sec.status === 'applied') {
|
||||||
return '✓ ' + sec.filename + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)';
|
return '✓ ' + escHtml(sec.filename) + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)';
|
||||||
}
|
}
|
||||||
if (sec.status === 'error') {
|
if (sec.status === 'error') {
|
||||||
return '✗ ' + sec.filename + ': ' + sec.error;
|
return '✗ ' + escHtml(sec.filename) + ': ' + sec.error;
|
||||||
}
|
}
|
||||||
return sec.filename;
|
return escHtml(sec.filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,7 +117,7 @@ function renderCompareOpsTable(ops) {
|
|||||||
// Цвет строки зависит от действия: ADD=зелёный, DELETE=красный, UPDATE=жёлтый
|
// Цвет строки зависит от действия: ADD=зелёный, DELETE=красный, UPDATE=жёлтый
|
||||||
var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
|
var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
|
||||||
var nr = op.new_row || {};
|
var nr = op.new_row || {};
|
||||||
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + (nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
|
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + escHtml(nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
|
||||||
});
|
});
|
||||||
html += '</tbody></table></div>';
|
html += '</tbody></table></div>';
|
||||||
return html;
|
return html;
|
||||||
|
|||||||
+55
-4
@@ -1,4 +1,55 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<rect width="32" height="32" rx="4" fill="#001C34"/>
|
<svg
|
||||||
<text x="16" y="24" text-anchor="middle" font-size="22" font-weight="bold" fill="white" font-family="sans-serif">N</text>
|
width="57"
|
||||||
</svg>
|
height="57"
|
||||||
|
xml:space="preserve"
|
||||||
|
overflow="hidden"
|
||||||
|
version="1.1"
|
||||||
|
id="svg8"
|
||||||
|
sodipodi:docname="U_v3.svg"
|
||||||
|
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview8"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="16.226667"
|
||||||
|
inkscape:cx="27.146672"
|
||||||
|
inkscape:cy="28.317584"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1494"
|
||||||
|
inkscape:window-x="-11"
|
||||||
|
inkscape:window-y="-11"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg8" /><defs
|
||||||
|
id="defs2"><clipPath
|
||||||
|
id="clip0"><rect
|
||||||
|
x="652"
|
||||||
|
y="420"
|
||||||
|
width="64"
|
||||||
|
height="75"
|
||||||
|
id="rect1" /></clipPath><clipPath
|
||||||
|
id="clip1"><rect
|
||||||
|
x="652"
|
||||||
|
y="420"
|
||||||
|
width="64"
|
||||||
|
height="70"
|
||||||
|
id="rect2" /></clipPath></defs><g
|
||||||
|
clip-path="url(#clip0)"
|
||||||
|
transform="matrix(1.0135748,0,0,1.0135748,-664.97034,-440.30567)"
|
||||||
|
id="g8"><g
|
||||||
|
clip-path="url(#clip1)"
|
||||||
|
id="g7"><g
|
||||||
|
id="g6"><path
|
||||||
|
d="m 114.028,43.1492 v 7.6592 c 0,3.7843 -3.08,6.8632 -6.866,6.8632 L 85.3367,57.5419 c -3.7853,0 -6.8655,-3.0805 -6.8655,-6.8643 v -2.5279 l 0.0555,0.009 V 15.8148 l -10.3823,2.0127 0.0045,3.8772 -0.0045,0.0015 v 28.971 c 0,9.481 7.7132,17.1923 17.1867,17.1923 l 21.8359,0.1313 c 9.474,0 17.187,-7.7117 17.187,-17.1928 v -2.6541 l 0.027,0.0045 V 15.8145 l -10.382,2.0126 0.028,25.3214 z"
|
||||||
|
fill="#001c34"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
transform="matrix(1,0,0,1.01337,587.92,420.059)"
|
||||||
|
id="path6" /></g></g></g></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 246 B After Width: | Height: | Size: 2.0 KiB |
+124
-231
@@ -1,27 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* files.js — Модуль работы с файлами (Фаза 1, decoupling-final-plan.md).
|
* files.js — Модуль работы с файлами.
|
||||||
*
|
*
|
||||||
* ВЫНЕСЕНО из app.js:
|
* ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔
|
||||||
* - statusToHTML(st) — чистая: структура → HTML
|
|
||||||
* - renderFiles(state) — рендер таблицы файлов
|
|
||||||
* - syncDB() — синхронизация БД с fileQueue
|
|
||||||
* - toggleClassifyDetail(i) — раскрыть результат классификации
|
|
||||||
* - uploadFile(file, cb) — XHR-загрузка одного файла (.doc/.docx/.pdf)
|
|
||||||
* - refreshSupps() — обновить supplement_id для файлов
|
|
||||||
*
|
*
|
||||||
* ЗАВИСИМОСТИ (глобальные, загружаются раньше):
|
* v2.0.3: честный счётчик ⏳ соединение... Nс вместо фейкового ↑N%
|
||||||
* state.js → state (центральное состояние)
|
* Проверено в бою 2026-07-16. Любое изменение = риск сломать загрузку.
|
||||||
* app_utils.js → escHtml, formatSize, formatDate
|
|
||||||
* app.js → render(), stepDone, stepActive, resetStepper, showClassifyBtn
|
|
||||||
*
|
*
|
||||||
* ЗАГРУЖАЕТСЯ: после app_utils.js, перед app.js
|
* КЛЮЧЕВЫЕ ФУНКЦИИ (не трогать):
|
||||||
*/
|
* - uploadFile() — fetch-загрузка с имитацией прогресса
|
||||||
|
* - onFilesSelected() — все строки в таблицу сразу, потом загрузка по одной
|
||||||
|
* - statusToHTML() — рендер статуса (↑ N%, ✓)
|
||||||
|
* - renderFiles() — рендер всей таблицы
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
|
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
|
||||||
*
|
*
|
||||||
* Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
|
* Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
|
||||||
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | ''
|
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | '' | 'connecting'
|
||||||
* Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
|
* Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
|
||||||
*
|
*
|
||||||
* ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
|
* ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
|
||||||
@@ -30,7 +25,9 @@
|
|||||||
function statusToHTML(st) {
|
function statusToHTML(st) {
|
||||||
if (!st || !st.kind) return '';
|
if (!st || !st.kind) return '';
|
||||||
switch (st.kind) {
|
switch (st.kind) {
|
||||||
case 'uploading': return '↑ ' + (st.pct || 0) + '%';
|
case 'connecting': return '⏳ соединение... ' + (st.elapsed || 0) + 'с';
|
||||||
|
case 'converting': return '⏳ конвертация... ' + (st.elapsed || 0) + 'с';
|
||||||
|
case 'uploading': return '⏳ отправка... ' + (st.elapsed || 0) + 'с';
|
||||||
case 'uploaded': return '<span class="status-ok">✓</span>';
|
case 'uploaded': return '<span class="status-ok">✓</span>';
|
||||||
case 'unzipping': return '⏳ распаковка...';
|
case 'unzipping': return '⏳ распаковка...';
|
||||||
case 'parsing': return '⏳ парсинг...';
|
case 'parsing': return '⏳ парсинг...';
|
||||||
@@ -55,7 +52,7 @@ function statusToHTML(st) {
|
|||||||
*/
|
*/
|
||||||
function renderFiles(state) {
|
function renderFiles(state) {
|
||||||
if (state.files.length === 0) {
|
if (state.files.length === 0) {
|
||||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
|
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .doc / .docx / .pdf</td></tr>';
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -208,64 +205,62 @@ window.toggleClassifyDetail = async function(i) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* uploadFile(file, onProgress) — XHR-загрузка одного файла на бэкенд.
|
* ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ uploadFile — загрузка через ВМ-буфер (паттерн drhider).
|
||||||
*
|
*
|
||||||
* Особенности:
|
* Фаза 1: PUT файла на ВМ (WebDAV /contracts-upload/, мимо шлюза кластера ~64КБ).
|
||||||
* - .doc (не .docx!) конвертируется через CONVERT_URL перед загрузкой
|
* Фаза 2: POST /api/upload_refs {files:[{name,size,url}]} — бэк сам тянет файл с ВМ.
|
||||||
* - onProgress(pct) — callback с процентом загрузки (0-100)
|
* Честный счётчик времени: ⏳ соединение... Nс → ⏳ отправка... Nс (fetch не даёт progress).
|
||||||
* - Возвращает Promise<ответ API> с полями doc_id, contract_id, parsed
|
|
||||||
* - Таймаут 180с (большие PDF)
|
|
||||||
*/
|
*/
|
||||||
function uploadFile(file, onProgress, zipSource) {
|
function uploadFile(file, onProgress, zipSource) {
|
||||||
return new Promise(function(resolve, reject) {
|
var startTime = Date.now();
|
||||||
// .doc → конвертация в docx (старый формат Word)
|
var phase = 'connecting'; // connecting → uploading
|
||||||
var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx');
|
if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 });
|
||||||
var uploadFile = file;
|
var token = crypto.randomUUID();
|
||||||
var uploadName = file.name;
|
var vmUrl = VM_UPLOAD_URL + token + '_0';
|
||||||
|
|
||||||
function doUpload() {
|
// Честный счётчик: каждую секунду обновляем elapsed
|
||||||
var xhr = new XMLHttpRequest();
|
var timer = setInterval(function() {
|
||||||
var fd = new FormData();
|
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
fd.append('files', uploadFile, uploadName);
|
if (onProgress) onProgress({ kind: phase, elapsed: elapsed });
|
||||||
if (state.contractId) fd.append('contract_id', state.contractId);
|
}, 1000);
|
||||||
fd.append('batch_id', state.batchId);
|
|
||||||
if (zipSource) fd.append('zip_source', zipSource);
|
|
||||||
xhr.open('POST', UPLOAD_URL);
|
|
||||||
xhr.upload.onprogress = function(e) {
|
|
||||||
if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100));
|
|
||||||
};
|
|
||||||
xhr.onload = function() {
|
|
||||||
try {
|
|
||||||
var r = JSON.parse(xhr.responseText);
|
|
||||||
if (r.ok) resolve(r);
|
|
||||||
else reject(new Error(r.error || 'Неизвестная ошибка'));
|
|
||||||
} catch(e) { reject(new Error('Некорректный ответ')); }
|
|
||||||
};
|
|
||||||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
|
||||||
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
|
||||||
xhr.timeout = 180000;
|
|
||||||
xhr.send(fd);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDoc) {
|
// Фаза 1: PUT файла на ВМ-буфер
|
||||||
var xhr = new XMLHttpRequest();
|
return fetch(vmUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' } })
|
||||||
xhr.open('POST', CONVERT_URL);
|
.then(function(r) {
|
||||||
xhr.responseType = 'blob';
|
if (!r.ok) throw new Error('VM upload HTTP ' + r.status);
|
||||||
xhr.onload = function() {
|
phase = 'uploading';
|
||||||
if (xhr.status === 200 && xhr.response.size > 100) {
|
// Фаза 2: refs на бэкенд → pull с ВМ
|
||||||
uploadFile = xhr.response;
|
var body = { batch_id: state.batchId, files: [{ name: file.name, size: file.size, url: vmUrl }] };
|
||||||
uploadName = file.name.replace(/\.doc$/i, '.docx');
|
if (state.contractId) body.contract_id = state.contractId;
|
||||||
doUpload();
|
if (zipSource) body.zip_source = zipSource;
|
||||||
} else {
|
return fetch('/api/upload_refs?_=' + Date.now(), {
|
||||||
reject(new Error('Конвертация .doc'));
|
method: 'POST',
|
||||||
}
|
headers: { 'Content-Type': 'application/json' },
|
||||||
};
|
body: JSON.stringify(body)
|
||||||
xhr.onerror = function() { reject(new Error('Конвертер')); };
|
});
|
||||||
xhr.send(file);
|
})
|
||||||
} else {
|
.then(function(r) {
|
||||||
doUpload();
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
}
|
return r.json();
|
||||||
});
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
clearInterval(timer);
|
||||||
|
if (data.ok && data.results && data.results.length === 1) {
|
||||||
|
var res = data.results[0];
|
||||||
|
if (!res.ok) throw new Error(res.error || 'Неизвестная ошибка');
|
||||||
|
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed });
|
||||||
|
return res; // {ok, doc_id, contract_id, parsed}
|
||||||
|
}
|
||||||
|
throw new Error(data.error || 'Неизвестная ошибка');
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
clearInterval(timer);
|
||||||
|
if (e.message === 'Failed to fetch' || e.name === 'TypeError') {
|
||||||
|
throw new Error('Сеть');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -340,150 +335,6 @@ function applyParseResult(entry, parsed, elapsed) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* addZipFile(file) — Async-action: обработка ZIP-файла (Фаза 1, упрощена).
|
|
||||||
*
|
|
||||||
* 1. Отправляет ZIP на бэкенд (только распаковка, без БД/парсинга)
|
|
||||||
* 2. Для каждого файла из архива: base64 → Blob → File → addRegularFile()
|
|
||||||
*
|
|
||||||
* addRegularFile делает ВСЁ: upload, parse, дубликаты, статусы, render.
|
|
||||||
* Никакого дублирования логики — единый путь для обычных и ZIP-файлов.
|
|
||||||
*/
|
|
||||||
async function addZipFile(file) {
|
|
||||||
var zipEntry = { name: file.name, lastModified: file.lastModified, size: file.size, status: { kind: 'unzipping' } };
|
|
||||||
state.files.push(zipEntry);
|
|
||||||
render(state);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Шаг 1: распаковать ZIP на бэкенде
|
|
||||||
var zipResp = await new Promise(function(resolve, reject) {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', UNZIP_URL);
|
|
||||||
xhr.responseType = 'json';
|
|
||||||
xhr.onload = function() { resolve(xhr.response); };
|
|
||||||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
|
||||||
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
|
||||||
xhr.timeout = 60000;
|
|
||||||
var fd = new FormData();
|
|
||||||
fd.append('files', file);
|
|
||||||
xhr.send(fd);
|
|
||||||
});
|
|
||||||
if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed');
|
|
||||||
|
|
||||||
// Подтверждение: показать первые 10 файлов + итог
|
|
||||||
var preview = zipResp.files.slice(0, 10).map(function(f) { return ' • ' + f.filename + ' (' + (f.size/1024).toFixed(1) + ' KB)'; }).join('\n');
|
|
||||||
if (total > 10) preview += '\n ... и ещё ' + (total - 10) + ' файлов';
|
|
||||||
if (!confirm('Архив «' + file.name + '» — ' + total + ' файлов:\n\n' + preview + '\n\nЗагрузить эти файлы?')) {
|
|
||||||
// Отмена — убрать строку ZIP
|
|
||||||
var zipPos2 = state.files.indexOf(zipEntry);
|
|
||||||
if (zipPos2 >= 0) state.files.splice(zipPos2, 1);
|
|
||||||
render(state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Шаг 2: обработать каждый файл из архива
|
|
||||||
// Временная строка ZIP остаётся в таблице — обновляем её статус
|
|
||||||
var total = zipResp.files.length;
|
|
||||||
for (var zi = 0; zi < total; zi++) {
|
|
||||||
var zf = zipResp.files[zi];
|
|
||||||
zipEntry.status = { kind: 'unzipping' }; // обновляем статус
|
|
||||||
render(state);
|
|
||||||
|
|
||||||
if (zf.error) continue;
|
|
||||||
|
|
||||||
// base64 → File → addRegularFile (единый путь)
|
|
||||||
var byteStr = atob(zf.data_b64);
|
|
||||||
var bytes = new Uint8Array(byteStr.length);
|
|
||||||
for (var b = 0; b < byteStr.length; b++) bytes[b] = byteStr.charCodeAt(b);
|
|
||||||
var mime = {docx:'application/vnd.openxmlformats-officedocument.wordprocessingml.document',doc:'application/msword',pdf:'application/pdf'}[zf.ext] || 'application/octet-stream';
|
|
||||||
var extractedFile = new File([bytes], zf.filename, { type: mime, lastModified: Date.now() });
|
|
||||||
|
|
||||||
await addRegularFile(extractedFile, file.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Шаг 3: убрать временную строку ZIP (только после успешной обработки всех файлов)
|
|
||||||
var zipPos = state.files.indexOf(zipEntry);
|
|
||||||
if (zipPos >= 0) state.files.splice(zipPos, 1);
|
|
||||||
render(state);
|
|
||||||
|
|
||||||
} catch(ze) {
|
|
||||||
// Ошибка — показать на строке ZIP (zipEntry всё ещё в state.files)
|
|
||||||
zipEntry.status = { kind: 'error', text: 'ZIP: ' + ze.message };
|
|
||||||
render(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* addRegularFile(file) — Async-action: обработка обычного файла (Фаза 1).
|
|
||||||
*
|
|
||||||
* 1. Добавляет/заменяет файл в state.files
|
|
||||||
* 2. Загружает через XHR (uploadFile) с индикатором прогресса
|
|
||||||
* 3. После загрузки: проставляет doc_id, contractId
|
|
||||||
* 4. applyParseResult — применяет результат парсинга
|
|
||||||
*
|
|
||||||
* Мутирует state.files, вызывает render(state) после каждого изменения.
|
|
||||||
*/
|
|
||||||
async function addRegularFile(file, zipSource) {
|
|
||||||
// Создать запись с начальным статусом
|
|
||||||
var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'uploading', pct: 0 }, zip_source: zipSource || null };
|
|
||||||
|
|
||||||
// ДЕДУПЛИКАЦИЯ: ключ = (zip_source, name), чтобы одноимённые файлы из разных ZIP не затирались
|
|
||||||
var dupKey = (zipSource || '') + '/' + file.name;
|
|
||||||
var existingIdx = -1;
|
|
||||||
for (var j = 0; j < state.files.length; j++) {
|
|
||||||
var ejKey = (state.files[j].zip_source || '') + '/' + state.files[j].name;
|
|
||||||
if (ejKey === dupKey) { existingIdx = j; break; }
|
|
||||||
}
|
|
||||||
var rowIdx;
|
|
||||||
if (existingIdx >= 0) {
|
|
||||||
// Файл с таким именем уже есть — спросить пользователя
|
|
||||||
if (!confirm('Файл «' + file.name + '» уже есть в списке.\n\nOK — перезаписать\nОтмена — пропустить')) {
|
|
||||||
return; // пользователь выбрал «пропустить»
|
|
||||||
}
|
|
||||||
state.files[existingIdx] = entry;
|
|
||||||
rowIdx = existingIdx;
|
|
||||||
} else {
|
|
||||||
state.files.push(entry);
|
|
||||||
rowIdx = state.files.length - 1;
|
|
||||||
}
|
|
||||||
render(state);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// XHR-загрузка с прогрессом
|
|
||||||
var resp = await uploadFile(file, function(pct) {
|
|
||||||
state.files[rowIdx].status = { kind: 'uploading', pct: pct };
|
|
||||||
render(state);
|
|
||||||
}, zipSource);
|
|
||||||
// Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys)
|
|
||||||
if (resp && resp.contract_id) state.contractId = resp.contract_id;
|
|
||||||
state.files[rowIdx].doc_id = resp.doc_id;
|
|
||||||
|
|
||||||
// Дубликат?
|
|
||||||
if (resp && resp.duplicate_of) {
|
|
||||||
state.files[rowIdx].status = { kind: 'duplicate' };
|
|
||||||
render(state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Warning от парсинга?
|
|
||||||
if (resp && resp.warning) {
|
|
||||||
state.files[rowIdx].status = { kind: 'warning', text: resp.warning };
|
|
||||||
render(state);
|
|
||||||
} else {
|
|
||||||
state.files[rowIdx].status = { kind: 'uploaded' };
|
|
||||||
}
|
|
||||||
state.files[rowIdx].uploaded = true;
|
|
||||||
|
|
||||||
// Авто-парсинг: бэкенд парсит всё (PDF + DOCX + DOC)
|
|
||||||
var t0 = Date.now();
|
|
||||||
var pr = resp.parsed;
|
|
||||||
var elapsed = pr && pr.status === 'parsed' ? ((Date.now() - t0) / 1000).toFixed(1) : null;
|
|
||||||
applyParseResult(state.files[rowIdx], pr, elapsed);
|
|
||||||
} catch(err) {
|
|
||||||
state.files[rowIdx].status = { kind: 'error', text: err.message };
|
|
||||||
}
|
|
||||||
render(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* finalizeUpload() — Завершение цикла загрузки (Фаза 1).
|
* finalizeUpload() — Завершение цикла загрузки (Фаза 1).
|
||||||
*
|
*
|
||||||
@@ -511,36 +362,78 @@ async function finalizeUpload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* onFilesSelected(newFiles) — Оркестратор загрузки (Фаза 1).
|
* expandZipClient(f) — клиентское рекурсивное раскрытие ZIP (drhider listZipFiles).
|
||||||
|
* Каждый документ из архива (включая вложенные zip) добавляется в state.files
|
||||||
|
* с zip_source = имя архива. Fallback: если раскрыть не удалось — архив как есть.
|
||||||
|
*/
|
||||||
|
async function expandZipClient(f) {
|
||||||
|
var nested = [];
|
||||||
|
try {
|
||||||
|
if (window.listZipFiles) {
|
||||||
|
nested = await window.listZipFiles(f, ['.pdf', '.doc', '.docx']);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
nested = [];
|
||||||
|
}
|
||||||
|
if (!nested || nested.length === 0) {
|
||||||
|
state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null });
|
||||||
|
state.files[state.files.length - 1]._pendingFile = f;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (var z = 0; z < nested.length; z++) {
|
||||||
|
state.files.push({ name: nested[z].name, lastModified: nested[z].lastModified, size: nested[z].size, file: nested[z], status: { kind: 'connecting', elapsed: 0 }, zip_source: f.name });
|
||||||
|
state.files[state.files.length - 1]._pendingFile = nested[z];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⛔ НЕ МЕНЯТЬ ⛔ onFilesSelected — все строки сразу, потом загрузка.
|
||||||
*
|
*
|
||||||
* ПАТТЕРН:
|
* v2.0.2: Фаза 1 — все строки в таблицу. Фаза 2 — загрузка по одной.
|
||||||
* 1. Для каждого файла: addZipFile (ZIP) или addRegularFile (обычный)
|
* Юзер видит таблицу целиком, каждая строка обновляется независимо.
|
||||||
* 2. finalizeUpload — завершить цикл
|
|
||||||
*
|
|
||||||
* НЕ удаляет существующие файлы — только добавляет новые.
|
|
||||||
* Дубликаты обрабатываются через confirm() в addRegularFile.
|
|
||||||
* Удаление — только вручную (кнопка ✕).
|
|
||||||
*
|
|
||||||
* Вызывается из fileInput.addEventListener('change', ...).
|
|
||||||
*/
|
*/
|
||||||
async function onFilesSelected(newFiles) {
|
async function onFilesSelected(newFiles) {
|
||||||
if (newFiles.length === 0) return;
|
if (newFiles.length === 0) return;
|
||||||
|
|
||||||
fileInput.disabled = true;
|
fileInput.disabled = true;
|
||||||
|
|
||||||
// Сбросить прогресс пайплайна при добавлении новых файлов
|
|
||||||
resetStepper('stepUpload');
|
resetStepper('stepUpload');
|
||||||
|
|
||||||
// Обработать каждый файл
|
// Фаза 1: ВСЕ строки в таблицу сразу
|
||||||
for (var i = 0; i < newFiles.length; i++) {
|
for (var i = 0; i < newFiles.length; i++) {
|
||||||
var f = newFiles[i];
|
var f = newFiles[i];
|
||||||
if (f.name.toLowerCase().endsWith('.zip')) {
|
if (f.name.toLowerCase().endsWith('.zip')) {
|
||||||
await addZipFile(f);
|
await expandZipClient(f);
|
||||||
} else {
|
continue;
|
||||||
await addRegularFile(f);
|
|
||||||
}
|
}
|
||||||
|
state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null });
|
||||||
|
state.files[state.files.length - 1]._pendingFile = f;
|
||||||
|
}
|
||||||
|
render(state);
|
||||||
|
|
||||||
|
// Фаза 2: загрузка по одному с обновлением строки
|
||||||
|
for (var j = 0; j < state.files.length; j++) {
|
||||||
|
var entry = state.files[j];
|
||||||
|
var pendingFile = entry._pendingFile;
|
||||||
|
if (!pendingFile) continue;
|
||||||
|
delete entry._pendingFile;
|
||||||
|
var f = pendingFile;
|
||||||
|
try {
|
||||||
|
var resp = await uploadFile(f, function(st) {
|
||||||
|
entry.status = st;
|
||||||
|
render(state);
|
||||||
|
}, entry.zip_source);
|
||||||
|
if (resp && resp.contract_id) state.contractId = resp.contract_id;
|
||||||
|
entry.doc_id = resp.doc_id;
|
||||||
|
entry.status = { kind: 'uploaded' };
|
||||||
|
entry.uploaded = true;
|
||||||
|
var pr = resp.parsed;
|
||||||
|
var elapsed = pr && pr.status === 'parsed' ? '0.0' : null;
|
||||||
|
applyParseResult(entry, pr, elapsed);
|
||||||
|
} catch(err) {
|
||||||
|
entry.status = { kind: 'error', text: err.message };
|
||||||
|
}
|
||||||
|
render(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Завершить цикл
|
|
||||||
await finalizeUpload();
|
await finalizeUpload();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
}
|
}
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body { font: 14px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); }
|
body { font: 14px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); }
|
||||||
.header { background: var(--card); border-bottom: 1px solid var(--border); padding: 12px 24px; display: flex; align-items: center; gap: 12px; }
|
.header { background: var(--card); border-bottom: 1px solid var(--border); padding: 12px 24px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 100; }
|
||||||
.header img { height: 24px; }
|
.header img { height: 24px; }
|
||||||
.header .sep { color: var(--muted); }
|
.header .sep { color: var(--muted); }
|
||||||
.header a { color: var(--brand); text-decoration: none; font-size: 13px; }
|
.header a { color: var(--brand); text-decoration: none; font-size: 13px; }
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1000; justify-content: center; align-items: flex-start; padding-top: 60px; }
|
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1000; justify-content: center; align-items: flex-start; padding-top: 60px; }
|
||||||
.modal-overlay.show { display: flex; }
|
.modal-overlay.show { display: flex; }
|
||||||
.modal { background: var(--card); border-radius: 12px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.15); }
|
.modal { background: var(--card); border-radius: 12px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.15); }
|
||||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--card); z-index: 1; }
|
||||||
.modal-header h2 { font-size: 16px; }
|
.modal-header h2 { font-size: 16px; }
|
||||||
.modal-close { cursor: pointer; font-size: 20px; color: var(--muted); background: none; border: none; line-height: 1; }
|
.modal-close { cursor: pointer; font-size: 20px; color: var(--muted); background: none; border: none; line-height: 1; }
|
||||||
.modal-close:hover { color: var(--fg); }
|
.modal-close:hover { color: var(--fg); }
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
<a href="https://contractor.pythonk8s.services.ngcloud.ru/">Сверка договоров</a>
|
<a href="https://contractor.pythonk8s.services.ngcloud.ru/">Сверка договоров</a>
|
||||||
<span class="sep">|</span>
|
<span class="sep">|</span>
|
||||||
<strong>DrHider</strong>
|
<strong>DrHider</strong>
|
||||||
<span style="font-size:11px;color:var(--muted);">v1.8</span>
|
<span style="font-size:11px;color:var(--muted);">v1.15</span>
|
||||||
<button class="help-btn" onclick="openModal()" title="О сервисе">?</button>
|
<button class="help-btn" onclick="openModal()" title="О сервисе">?</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
<h3>✅ Форматы файлов</h3>
|
<h3>✅ Форматы файлов</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>.docx</strong> — полная замена с сохранением структуры документа. ⚠️ Форматирование (жирный, курсив) заменённых фрагментов может сброситься.</li>
|
<li><strong>.docx</strong> — полная замена с сохранением структуры документа. ⚠️ Форматирование (жирный, курсив) заменённых фрагментов может сброситься.</li>
|
||||||
<li><strong>.pdf</strong> — текст извлекается и обфусцируется. Результат: <code>.txt</code>. Форматирование, таблицы и графика не сохраняются.</li>
|
<li><strong>.pdf</strong> — текст и таблицы извлекаются в .docx, затем обфусцируется. Шрифты, цвета и PDF-вёрстка не сохраняются. При совпадении имён с существующим .docx добавляется суффикс <code>_из_pdf</code>.</li>
|
||||||
<li><strong>.doc</strong> — бинарный формат, <strong>не обфусцируется</strong>. Файл возвращается как есть. Конвертируйте в .docx перед загрузкой.</li>
|
<li><strong>.doc</strong> — бинарный формат, <strong>не обфусцируется</strong>. Файл возвращается как есть. Конвертируйте в .docx перед загрузкой.</li>
|
||||||
<li><strong>.zip</strong> — автоматически распаковывается, все файлы внутри обрабатываются.</li>
|
<li><strong>.zip</strong> — автоматически распаковывается, все файлы внутри обрабатываются.</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
<strong>.doc не обрабатывается:</strong> старые Word-файлы возвращаются без изменений.
|
<strong>.doc не обрабатывается:</strong> старые Word-файлы возвращаются без изменений.
|
||||||
</div>
|
</div>
|
||||||
<div class="warn">
|
<div class="warn">
|
||||||
<strong>PDF → текст:</strong> таблицы, графика и форматирование теряются.
|
<strong>PDF → DOCX:</strong> извлекаются только текст и таблицы. Шрифты, цвета и позиционирование теряются. Сканы (фотографии страниц) не распознаются.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="warn">
|
<div class="warn">
|
||||||
<strong>Сканы/изображения:</strong> текст на картинках не распознаётся (нет OCR).
|
<strong>Сканы/изображения:</strong> текст на картинках не распознаётся (нет OCR).
|
||||||
@@ -204,13 +204,57 @@ DZ.ondrop = e => { e.preventDefault(); DZ.classList.remove('active'); addFiles(e
|
|||||||
function addFiles(fl) { for (let f of fl) { if (files.find(x => x.name===f.name && x.size===f.size)) continue; files.push(f); } render(); }
|
function addFiles(fl) { for (let f of fl) { if (files.find(x => x.name===f.name && x.size===f.size)) continue; files.push(f); } render(); }
|
||||||
function remove(i) { files.splice(i, 1); render(); }
|
function remove(i) { files.splice(i, 1); render(); }
|
||||||
function render() {
|
function render() {
|
||||||
LIST.innerHTML = files.map((f,i) => `<div class="file-row"><span class="name">${esc(f.name)}</span><span class="size">${fmt(f.size)}</span><span class="remove" onclick="remove(${i})">×</span></div>`).join('');
|
LIST.innerHTML = files.map((f,i) => {
|
||||||
|
let display = esc(f.name);
|
||||||
|
if (f.name.toLowerCase().endsWith('.pdf')) {
|
||||||
|
display += ' → <span style="color:var(--brand-primary)">*.docx</span>';
|
||||||
|
}
|
||||||
|
return `<div class="file-row"><span class="name">${display}</span><span class="size">${fmt(f.size)}</span><span class="remove" onclick="remove(${i})">×</span></div>`;
|
||||||
|
}).join('');
|
||||||
LIST.className = files.length ? 'show' : '';
|
LIST.className = files.length ? 'show' : '';
|
||||||
BTN.disabled = !files.length;
|
BTN.disabled = !files.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
BTN.onclick = () => {
|
BTN.onclick = () => {
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
|
// Проверить коллизии PDF→DOCX
|
||||||
|
const collisions = [];
|
||||||
|
const names = files.map(f => f.name);
|
||||||
|
files.forEach(f => {
|
||||||
|
if (f.name.toLowerCase().endsWith('.pdf')) {
|
||||||
|
const docxName = f.name.slice(0, -4) + '.docx';
|
||||||
|
if (names.includes(docxName)) {
|
||||||
|
collisions.push({pdf: f.name, docx: docxName});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (collisions.length) {
|
||||||
|
showCollisionModal(collisions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
doSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
function showCollisionModal(collisions) {
|
||||||
|
const list = collisions.map(c => `<div style="margin-bottom:4px">📄 <b>${esc(c.pdf)}</b> → <b>${esc(c.docx)}</b> (уже есть)</div>`).join('');
|
||||||
|
document.getElementById('collisionList').innerHTML = list;
|
||||||
|
document.getElementById('collisionOverlay').classList.add('show');
|
||||||
|
window._collisions = collisions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCollision(skipAll) {
|
||||||
|
document.getElementById('collisionOverlay').classList.remove('show');
|
||||||
|
if (skipAll) {
|
||||||
|
// Удалить PDF-файлы из списка
|
||||||
|
const pdfNames = window._collisions.map(c => c.pdf);
|
||||||
|
files = files.filter(f => !pdfNames.includes(f.name));
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
// В любом случае отправляем (бэкенд сам переименует)
|
||||||
|
setTimeout(doSubmit, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSubmit() {
|
||||||
BTN.disabled = true;
|
BTN.disabled = true;
|
||||||
setStatus('progress', '⏳ Отправка...');
|
setStatus('progress', '⏳ Отправка...');
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
@@ -248,7 +292,24 @@ function fmt(n) { return n>1e6 ? (n/1e6).toFixed(1)+' MB' : n>1e3 ? (n/1e3).toFi
|
|||||||
// ── Модальное окно ─────────────────────────────────────────────
|
// ── Модальное окно ─────────────────────────────────────────────
|
||||||
function openModal() { document.getElementById('modalOverlay').classList.add('show'); }
|
function openModal() { document.getElementById('modalOverlay').classList.add('show'); }
|
||||||
function closeModal() { document.getElementById('modalOverlay').classList.remove('show'); }
|
function closeModal() { document.getElementById('modalOverlay').classList.remove('show'); }
|
||||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
|
// ── Коллизия PDF→DOCX ──────────────────────────────────────────
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeCollision(true); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Модальное окно: коллизия PDF→DOCX -->
|
||||||
|
<div class="modal-overlay" id="collisionOverlay">
|
||||||
|
<div class="modal" style="max-width:480px">
|
||||||
|
<div class="modal-header">⚠️ Конфликт имён</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p style="margin-bottom:10px">PDF-файлы конвертируются в DOCX. Имена совпадают:</p>
|
||||||
|
<div id="collisionList" style="margin-bottom:12px"></div>
|
||||||
|
<p style="font-size:12px;color:var(--muted)">«Перезаписать» — PDF-файл заменит DOCX.<br>«Пропустить» — PDF будут удалены из списка.</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;padding:12px 16px;border-top:1px solid var(--brand-gray)">
|
||||||
|
<button class="btn" onclick="closeCollision(true)" style="flex:1">Пропустить PDF</button>
|
||||||
|
<button class="btn btn-primary" onclick="closeCollision(false)" style="flex:1">Перезаписать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img src="/static/logo.svg" alt="Nubes">
|
<img src="/static/logo.svg" alt="Nubes">
|
||||||
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.195-flask</span></span>
|
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.21</span></span>
|
||||||
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
||||||
<span id="stepUpload">○ Загрузка</span><span>→</span>
|
<span id="stepUpload">○ Загрузка</span><span>→</span>
|
||||||
<span id="stepClassify">○ Классификация</span><span>→</span>
|
<span id="stepClassify">○ Классификация</span><span>→</span>
|
||||||
@@ -90,7 +90,11 @@
|
|||||||
Загрузка договоров/приложений/спецификаций
|
Загрузка договоров/приложений/спецификаций
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<input type="file" id="fileInput" accept=".docx,.doc,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
|
<input type="file" id="fileInput" accept=".doc,.docx,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
|
||||||
|
<input type="file" id="folderInput" webkitdirectory multiple style="display:none;">
|
||||||
|
<div style="margin-bottom:6px;">
|
||||||
|
<button type="button" class="btn" id="folderBtn" style="font-size:12px;height:30px;">📁 Выбрать папку</button>
|
||||||
|
</div>
|
||||||
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</div>
|
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</div>
|
||||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
|
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
|
||||||
|
|
||||||
@@ -216,11 +220,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/state.js?v=1.0.179-flask"></script>
|
<script type="module">
|
||||||
<script src="/static/app_utils.js?v=1.0.179-flask"></script>
|
import { listZipFiles } from '/upload/zip/list_zip_files.js';
|
||||||
<script src="/static/files.js?v=1.0.179-flask"></script>
|
window.listZipFiles = listZipFiles;
|
||||||
<script src="/static/groups.js?v=1.0.179-flask"></script>
|
</script>
|
||||||
<script src="/static/compare.js?v=1.0.179-flask"></script>
|
<script src="/static/state.js?v=2.0.21"></script>
|
||||||
<script src="/static/app.js?v=1.0.179-flask"></script>
|
<script src="/static/app_utils.js?v=2.0.21"></script>
|
||||||
|
<script src="/static/files.js?v=2.0.21"></script>
|
||||||
|
<script src="/static/groups.js?v=2.0.21"></script>
|
||||||
|
<script src="/static/compare.js?v=2.0.21"></script>
|
||||||
|
<script src="/static/app.js?v=2.0.21"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Тесты contracts-flask
|
||||||
|
|
||||||
|
Набор pytest-тестов под актуальный код сервиса (`site/`).
|
||||||
|
Покрытие: юнит (чистая логика), интеграционные (SQLite), HTTP-эндпоинты (Flask test client), безопасность.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd contracts-flask
|
||||||
|
pytest tests/ -q
|
||||||
|
```
|
||||||
|
|
||||||
|
pytest установлен в venv: `/home/naeel/nubes/contracts/.venv/bin/python -m pytest tests/ -q`.
|
||||||
|
|
||||||
|
## Файлы
|
||||||
|
|
||||||
|
| Файл | Что тестирует |
|
||||||
|
|---|---|
|
||||||
|
| `conftest.py` | site/ в `sys.path`; фикстура `db` — изолированная БД (временный `DB_PATH` + свежая схема); защита реальной `/tmp/contracts.db` от пересоздания |
|
||||||
|
| `test_metrics.py` | `services/metrics.py`: `normalize_date`, `check_arithmetic` (sum == price×qty), `_to_decimal`, `ClassifyMetrics` |
|
||||||
|
| `test_grouping.py` | `services/grouping.py`: `normalize_number`, `group_documents` (группировка contract+supplement, unresolved), `apply_groups` (mock db) |
|
||||||
|
| `test_llm_client.py` | `services/llm_client.py`: `FakeLLMClient` (точное/частичное совпадение, default, история вызовов), `HttpxLLMClient` |
|
||||||
|
| `test_llm.py` | `services/llm.py`: `call_llm` — парсинг plain JSON и markdown-обёрток (```json) |
|
||||||
|
| `test_classify.py` | `services/classify.py`: garbage-фильтры по имени/заголовку, `_safe_json_parse` (7 edge-case: trailing comma, chatter, пусто), `_smart_extract` |
|
||||||
|
| `test_parse.py` | `services/parse.py`: `parse_file` (text/docx/pdf, ошибки), `_parse_text` |
|
||||||
|
| `test_connection.py` | `db/connection.py`: `_pg_to_sqlite` (все замены %s/::jsonb/BOOLEAN/ILIKE/TRUE), `_extract_table` |
|
||||||
|
| `test_spec_events.py` | `db/spec_events.py`: `apply_ops` (ADD/UPDATE/DELETE/UNRESOLVED/unknown), `clear_current`, `reset`, `_hash`, `get_next_seq` |
|
||||||
|
| `test_spec_current.py` | `db/spec_current.py`: `list_by_contract` (порядок), `get_elements_json` |
|
||||||
|
| `test_process_pipeline.py` | `services/process.py`: `run_pipeline` с Fake LLM — **full_replace не дублирует строки**, **трансляция target_id→target_hash**, `_elements_to_text` |
|
||||||
|
| `test_routes.py` | HTTP-эндпоинты (Flask test client): `/health`, `/api/spec-current`, `/api/groups` |
|
||||||
|
|
||||||
|
## Ключевые проверки
|
||||||
|
|
||||||
|
- `test_full_replace_no_duplicates` — два `full_replace` подряд → в `spec_current` 3 строки, а не 6; история `spec_events` (6 событий) сохранена.
|
||||||
|
- `test_update_applies` — `target_id: "r1"` транслируется в `target_hash` → UPDATE применяется (status `applied`), а не уходит в UNRESOLVED.
|
||||||
|
|
||||||
|
## Изоляция
|
||||||
|
|
||||||
|
- Каждый тест, работающий с БД, получает свою копию SQLite через фикстуру `db`
|
||||||
|
(monkeypatch `DB_PATH` → `tmp_path`, `init_db()`).
|
||||||
|
- LLM-вызовы в пайплайне подменяются через `monkeypatch.setattr("services.llm.call_llm", ...)` — сеть не используется.
|
||||||
|
|
||||||
|
## Нагрузочные тесты (`tests/load/`, маркер `load`)
|
||||||
|
|
||||||
|
Долгие стресс-тесты, реально нагружают SQLite/пайплайн. Запуск отдельно:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest -m load -q # только нагрузочные
|
||||||
|
pytest -m "not load" -q # быстрые юнит-тесты без нагрузочных
|
||||||
|
```
|
||||||
|
|
||||||
|
| Файл | Что нагружает |
|
||||||
|
|---|---|
|
||||||
|
| `load/test_load_pipeline.py` | массовый `run_pipeline`: N контрактов × M допников (Fake LLM) — проверка, что `full_replace` не дублирует строки в масштабе |
|
||||||
|
| `load/test_load_apply_ops.py` | массовые `apply_ops`: N ADD → N UPDATE → N DELETE, целостность `spec_events` |
|
||||||
|
| `load/test_load_concurrency.py` | конкурентная запись в SQLite (WAL + `busy_timeout`), проверка отсутствия потери данных |
|
||||||
|
| `load/stress_http.py` | standalone HTTP-стресс: `PUT` на ВМ → `POST /api/upload_refs` (реальный VM-путь) + `/health` |
|
||||||
|
|
||||||
|
Масштаб через переменные окружения (дефолты — большие):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LOAD_CONTRACTS=100 LOAD_SUPPS=20 LOAD_SERVICES=10 \
|
||||||
|
LOAD_OPS=5000 LOAD_THREADS=4 LOAD_PER=200 \
|
||||||
|
pytest -m load -q
|
||||||
|
|
||||||
|
Примечание по конкурентной записи: стабильный уровень `LOAD_THREADS=4`
|
||||||
|
(= `MAX_WORKERS` приложения). `>= 8` — нестабильно: SQLite с
|
||||||
|
`busy_timeout=5000` даёт `database is locked` (см. `History/2026-08-26-load-sqlite-locked.md`).
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP-стресс против локального/прод сервиса:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/load/stress_http.py --url http://127.0.0.1:5000 --n 1000 --threads 32 --size 100000
|
||||||
|
```
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Общие фикстуры для тестов contracts-flask (модули site/).
|
||||||
|
|
||||||
|
Запуск: pytest tests/ -q
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# site/ в sys.path — импортируем config/db/services/routes напрямую
|
||||||
|
_SITE = os.path.join(os.path.dirname(__file__), "..", "site")
|
||||||
|
if _SITE not in sys.path:
|
||||||
|
sys.path.insert(0, _SITE)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True, scope="session")
|
||||||
|
def _isolate_global_db(tmp_path_factory):
|
||||||
|
"""Не дать импорту app.py пересоздать реальную /tmp/contracts.db."""
|
||||||
|
from db import connection as conn
|
||||||
|
conn.DB_PATH = str(tmp_path_factory.mktemp("dbs") / "session.db")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db(tmp_path, monkeypatch):
|
||||||
|
"""Изолированная БД: отдельный DB_PATH + свежая схема (init_db)."""
|
||||||
|
from db import connection as conn
|
||||||
|
monkeypatch.setattr(conn, "DB_PATH", str(tmp_path / "contracts_test.db"))
|
||||||
|
conn.init_db()
|
||||||
|
yield conn
|
||||||
|
# cleanup: закрыть thread-local соединение
|
||||||
|
c = getattr(conn._local, "conn", None)
|
||||||
|
if c is not None:
|
||||||
|
try:
|
||||||
|
c.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn._local.conn = None
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
config.addinivalue_line("markers", "load: долгие нагрузочные/стресс-тесты")
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Нагрузочные/стресс-тесты contracts-flask.
|
||||||
|
|
||||||
|
Запуск только нагрузочных:
|
||||||
|
pytest -m load -q
|
||||||
|
Запуск быстрых (юнит) без нагрузочных:
|
||||||
|
pytest -m "not load" -q
|
||||||
|
|
||||||
|
Масштаб настраивается переменными окружения (дефолты — большие):
|
||||||
|
LOAD_CONTRACTS, LOAD_SUPPS, LOAD_SERVICES, LOAD_OPS, LOAD_THREADS, LOAD_PER
|
||||||
|
"""
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""HTTP-стресс против contracts-flask (standalone, не pytest).
|
||||||
|
|
||||||
|
Реальный путь загрузки (VM-буфер):
|
||||||
|
1) PUT файла на ВМ WebDAV (мимо шлюза кластера ~64KB);
|
||||||
|
2) POST /api/upload_refs — бэк сам тянет файл с ВМ (egress).
|
||||||
|
|
||||||
|
Замеряет RPS, ошибки, таймауты. Цель — «разогреть» сервис и выявить
|
||||||
|
деградацию/обрывы (шлюз, OOM, SQLite).
|
||||||
|
|
||||||
|
Запуск (локально):
|
||||||
|
python tests/load/stress_http.py --url http://127.0.0.1:5000 --n 1000 --threads 32 --size 100000
|
||||||
|
|
||||||
|
Пример против прода (осторожно):
|
||||||
|
python tests/load/stress_http.py --url https://contractor.pythonk8s.dev.nubes.ru \
|
||||||
|
--file "/mnt/y/MY/Nubes/примеры_договоров_для_ИИ/спецификация-XXX001-03700.docx" \
|
||||||
|
--n 200 --threads 16
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="HTTP-стресс contracts-flask")
|
||||||
|
ap.add_argument("--url", default="http://127.0.0.1:5000")
|
||||||
|
ap.add_argument("--vm-url", default="https://contracts.kube5s.ru/contracts-upload/", help="ВМ WebDAV-буфер (PUT файла)")
|
||||||
|
ap.add_argument("--n", type=int, default=1000, help="число запросов")
|
||||||
|
ap.add_argument("--threads", type=int, default=32, help="параллельных потоков")
|
||||||
|
ap.add_argument("--size", type=int, default=100000, help="размер файла в байтах (если не задан --file)")
|
||||||
|
ap.add_argument("--file", default=None, help="путь к реальному файлу (заменяет сгенерированный)")
|
||||||
|
ap.add_argument("--health-only", action="store_true", help="только /health, без загрузки файлов")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.file:
|
||||||
|
import os as _os
|
||||||
|
fname = _os.path.basename(args.file)
|
||||||
|
with open(args.file, "rb") as _f:
|
||||||
|
payload = _f.read()
|
||||||
|
real_size = len(payload)
|
||||||
|
else:
|
||||||
|
fname = "load.docx"
|
||||||
|
payload = b"x" * args.size
|
||||||
|
real_size = args.size
|
||||||
|
ok = 0
|
||||||
|
err = 0
|
||||||
|
slow = 0
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
def one(_):
|
||||||
|
nonlocal ok, err, slow
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=30) as c:
|
||||||
|
if args.health_only:
|
||||||
|
r = c.get(f"{args.url}/health")
|
||||||
|
if r.status_code == 200:
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
err += 1
|
||||||
|
return
|
||||||
|
# Фаза 1: PUT файла на ВМ WebDAV (мимо шлюза ~64KB)
|
||||||
|
token = uuid.uuid4().hex
|
||||||
|
vm_url = args.vm_url.rstrip("/") + "/" + token + "_0"
|
||||||
|
r1 = c.put(vm_url, content=payload, headers={"Content-Type": "application/octet-stream"})
|
||||||
|
if not r1.is_success:
|
||||||
|
err += 1
|
||||||
|
return
|
||||||
|
# Фаза 2: POST /api/upload_refs — бэк тянет файл с ВМ (egress)
|
||||||
|
body = {
|
||||||
|
"batch_id": token,
|
||||||
|
"files": [{"name": fname, "size": real_size, "url": vm_url}],
|
||||||
|
}
|
||||||
|
r2 = c.post(f"{args.url}/api/upload_refs", json=body)
|
||||||
|
if r2.status_code == 200:
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
err += 1
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
err += 1
|
||||||
|
slow += 1
|
||||||
|
except Exception:
|
||||||
|
err += 1
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=args.threads) as ex:
|
||||||
|
list(ex.map(one, range(args.n)))
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
print(f"url={args.url}")
|
||||||
|
print(f"vm_url={args.vm_url}")
|
||||||
|
print(f"n={args.n} threads={args.threads} size={real_size}B file={args.file or '(gen)'} health_only={args.health_only}")
|
||||||
|
print(f"ok={ok} err={err} timeouts={slow} elapsed={elapsed:.1f}s rps={args.n / elapsed:.1f}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Нагрузочный тест: массовые apply_ops (ADD → UPDATE → DELETE).
|
||||||
|
|
||||||
|
Прогоняет N операций каждого типа одним вызовом apply_ops, замеряет время,
|
||||||
|
проверяет целостность spec_current/spec_events (ничего не потеряно, не продублировано).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from db import spec_events, spec_current
|
||||||
|
from db.connection import query
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.load
|
||||||
|
|
||||||
|
N = int(os.getenv("LOAD_OPS", "5000"))
|
||||||
|
|
||||||
|
CID = "load-cid-111"
|
||||||
|
SID = "load-sid-111"
|
||||||
|
DID = "load-did-111"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mass_apply_ops(db):
|
||||||
|
# ADD N строк
|
||||||
|
ops = [{"action": "ADD", "new_row": {"name": f"услуга {i}", "price": i, "qty": 1, "sum": i}} for i in range(N)]
|
||||||
|
t0 = time.time()
|
||||||
|
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
||||||
|
t_add = time.time() - t0
|
||||||
|
assert s["added"] == N
|
||||||
|
assert len(spec_current.list_by_contract(CID)) == N
|
||||||
|
|
||||||
|
# UPDATE всех N строк
|
||||||
|
hashes = [spec_events._hash(f"услуга {i}") for i in range(N)]
|
||||||
|
ups = [{"action": "UPDATE", "target_hash": h, "new_values": {"price": i + 1}} for i, h in enumerate(hashes)]
|
||||||
|
t0 = time.time()
|
||||||
|
s = spec_events.apply_ops(CID, SID, DID, ups, "pid", {})
|
||||||
|
t_upd = time.time() - t0
|
||||||
|
assert s["updated"] == N
|
||||||
|
assert spec_current.list_by_contract(CID)[0]["price"] == 1 # первая строка обновлена
|
||||||
|
|
||||||
|
# DELETE всех N строк
|
||||||
|
dels = [{"action": "DELETE", "target_hash": h} for h in hashes]
|
||||||
|
t0 = time.time()
|
||||||
|
s = spec_events.apply_ops(CID, SID, DID, dels, "pid", {})
|
||||||
|
t_del = time.time() - t0
|
||||||
|
assert s["deleted"] == N
|
||||||
|
assert spec_current.list_by_contract(CID) == []
|
||||||
|
|
||||||
|
total = query("SELECT count(*) AS c FROM spec_events WHERE contract_id = %s", (CID,))
|
||||||
|
assert total[0]["c"] == N * 3 # ADD + UPDATE + DELETE, ничего не потеряно
|
||||||
|
|
||||||
|
print(f"\n[load] ops={N} add={t_add:.1f}с upd={t_upd:.1f}с del={t_del:.1f}с")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Нагрузочный тест: конкурентная запись в SQLite (WAL + busy_timeout).
|
||||||
|
|
||||||
|
N_THREADS потоков пишут по PER ADD-операций каждый. Проверяет, что конкурентная
|
||||||
|
запись не теряет данные (WAL сериализует writers, busy_timeout ждёт).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from db import spec_events, spec_current
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.load
|
||||||
|
|
||||||
|
N_THREADS = int(os.getenv("LOAD_THREADS", "4"))
|
||||||
|
PER = int(os.getenv("LOAD_PER", "200"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_writes(db):
|
||||||
|
def writer(tid):
|
||||||
|
cid = f"c{tid}"
|
||||||
|
for i in range(PER):
|
||||||
|
spec_events.apply_ops(
|
||||||
|
cid, f"s{tid}", f"d{tid}",
|
||||||
|
[{"action": "ADD", "new_row": {"name": f"svc{i}", "price": i}}],
|
||||||
|
"pid", {},
|
||||||
|
)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=N_THREADS) as ex:
|
||||||
|
list(ex.map(writer, range(N_THREADS)))
|
||||||
|
|
||||||
|
# Никаких потерь: у каждого потока ровно PER строк
|
||||||
|
for tid in range(N_THREADS):
|
||||||
|
n = len(spec_current.list_by_contract(f"c{tid}"))
|
||||||
|
assert n == PER, f"c{tid}: {n} != {PER} (потеря данных при конкурентной записи)"
|
||||||
|
|
||||||
|
print(f"\n[load] потоков={N_THREADS} строк/поток={PER} всего={N_THREADS * PER}")
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Нагрузочный тест: массовый прогон run_pipeline (сотни контрактов × допников).
|
||||||
|
|
||||||
|
Создаёт N контрактов × M допников, прогоняет полный конвейер сверки с Fake LLM
|
||||||
|
(без сети), реально нагружает SQLite: apply_ops + clear_current + reset.
|
||||||
|
|
||||||
|
Проверка: full_replace НЕ дублирует строки в масштабе (у каждого контракта ровно
|
||||||
|
SERVICES строк, а не SERVICES × M_SUPPS).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from db import contracts, documents, supplements, spec_current
|
||||||
|
from db.connection import query
|
||||||
|
from services import process
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.load
|
||||||
|
|
||||||
|
N_CONTRACTS = int(os.getenv("LOAD_CONTRACTS", "100"))
|
||||||
|
M_SUPPS = int(os.getenv("LOAD_SUPPS", "20"))
|
||||||
|
SERVICES = int(os.getenv("LOAD_SERVICES", "10"))
|
||||||
|
|
||||||
|
|
||||||
|
def _seed():
|
||||||
|
cids = []
|
||||||
|
for ci in range(N_CONTRACTS):
|
||||||
|
c = contracts.insert(f"03700_{ci}")
|
||||||
|
for mi in range(M_SUPPS):
|
||||||
|
d = documents.insert(f"d{ci}_{mi}.docx", "application/octet-stream", "raw", batch_id=f"b{ci}")
|
||||||
|
documents.set_parsed(d["id"], [{"type": "paragraph", "text": f"услуга {mi}"}])
|
||||||
|
supplements.insert(c["id"], d["id"], "initial" if mi == 0 else "additional")
|
||||||
|
cids.append(c["id"])
|
||||||
|
return cids
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_llm():
|
||||||
|
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
|
||||||
|
ops = [
|
||||||
|
{"action": "ADD", "new_row": {"name": f"услуга {i}", "price": 100 + i, "qty": 1, "sum": 100 + i}}
|
||||||
|
for i in range(SERVICES)
|
||||||
|
]
|
||||||
|
return ({"mode": "full_replace", "ops": ops}, "pid")
|
||||||
|
return fake_call
|
||||||
|
|
||||||
|
|
||||||
|
def test_mass_pipeline(db, monkeypatch):
|
||||||
|
cids = _seed()
|
||||||
|
monkeypatch.setattr("services.llm.call_llm", _fake_llm())
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
for cid in cids:
|
||||||
|
list(process.run_pipeline(cid, "", lambda cur, txt: ("p", "pid")))
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
|
||||||
|
# Целостность: у каждого контракта ровно SERVICES строк (full_replace без дублей)
|
||||||
|
bad = 0
|
||||||
|
for cid in cids:
|
||||||
|
if len(spec_current.list_by_contract(cid)) != SERVICES:
|
||||||
|
bad += 1
|
||||||
|
assert bad == 0, f"{bad} контрактов с неверным числом строк"
|
||||||
|
|
||||||
|
total_events = query("SELECT count(*) AS c FROM spec_events", ())
|
||||||
|
print(
|
||||||
|
f"\n[load] контрактов={N_CONTRACTS} допников={M_SUPPS} услуг={SERVICES} "
|
||||||
|
f"событий={total_events[0]['c']} время={elapsed:.1f}с"
|
||||||
|
)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Unit-тесты services/classify.py — фильтры + JSON-парсинг + выжимка."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.classify import (
|
||||||
|
_is_garbage_by_filename,
|
||||||
|
_is_garbage_by_header,
|
||||||
|
_safe_json_parse,
|
||||||
|
_smart_extract,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGarbageFilename:
|
||||||
|
def test_invoice(self):
|
||||||
|
assert _is_garbage_by_filename("счет-фактура №123.docx") is True
|
||||||
|
|
||||||
|
def test_act(self):
|
||||||
|
assert _is_garbage_by_filename("Акт сверки.pdf") is True
|
||||||
|
|
||||||
|
def test_upd(self):
|
||||||
|
assert _is_garbage_by_filename("УПД-2025.docx") is True
|
||||||
|
|
||||||
|
def test_not_garbage(self):
|
||||||
|
assert _is_garbage_by_filename("договор.docx") is False
|
||||||
|
assert _is_garbage_by_filename("спецификация.pdf") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestGarbageHeader:
|
||||||
|
def test_invoice_header(self):
|
||||||
|
assert _is_garbage_by_header("СЧЕТ-ФАКТУРА № 123 от 01.01.2026") is True
|
||||||
|
|
||||||
|
def test_act_header(self):
|
||||||
|
assert _is_garbage_by_header("АКТ ОКАЗАННЫХ УСЛУГ за май") is True
|
||||||
|
|
||||||
|
def test_not_garbage(self):
|
||||||
|
assert _is_garbage_by_header("ДОГОВОР № 03700_1 об оказании услуг") is False
|
||||||
|
|
||||||
|
def test_upd_mention_in_contract_is_not_garbage(self):
|
||||||
|
text = "Договор № 03700. Оплата производится на основании УПД и счета."
|
||||||
|
assert _is_garbage_by_header(text) is False
|
||||||
|
|
||||||
|
def test_upd_document_header_is_garbage(self):
|
||||||
|
assert _is_garbage_by_header("УПД № 123 от 01.01.2026") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafeJsonParse:
|
||||||
|
def test_valid(self):
|
||||||
|
d, fix = _safe_json_parse('{"a":1}')
|
||||||
|
assert d == {"a": 1}
|
||||||
|
assert fix is False
|
||||||
|
|
||||||
|
def test_markdown(self):
|
||||||
|
d, fix = _safe_json_parse('```json\n{"a":1}\n```')
|
||||||
|
assert d == {"a": 1}
|
||||||
|
assert fix is False
|
||||||
|
|
||||||
|
def test_chatter(self):
|
||||||
|
d, _ = _safe_json_parse('Вот ответ: {"a":1}. Спасибо!')
|
||||||
|
assert d == {"a": 1}
|
||||||
|
|
||||||
|
def test_trailing_comma(self):
|
||||||
|
d, fix = _safe_json_parse('{"a":1,}')
|
||||||
|
assert d == {"a": 1}
|
||||||
|
assert fix is True
|
||||||
|
|
||||||
|
def test_trailing_comma_in_list(self):
|
||||||
|
d, fix = _safe_json_parse('{"a":[1,2,]}')
|
||||||
|
assert d == {"a": [1, 2]}
|
||||||
|
assert fix is True
|
||||||
|
|
||||||
|
def test_empty_raises(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_safe_json_parse("")
|
||||||
|
|
||||||
|
def test_none_raises(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_safe_json_parse(None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSmartExtract:
|
||||||
|
def test_paragraphs(self):
|
||||||
|
ej = [{"type": "paragraph", "text": "Договор № 03700"}]
|
||||||
|
out = _smart_extract(ej)
|
||||||
|
assert "Договор" in out
|
||||||
|
|
||||||
|
def test_table(self):
|
||||||
|
ej = [{"type": "table", "rows": [["Аренда", "50000"]]}]
|
||||||
|
out = _smart_extract(ej)
|
||||||
|
assert "Аренда" in out
|
||||||
|
|
||||||
|
def test_string_json(self):
|
||||||
|
ej = json.dumps([{"type": "paragraph", "text": "Спецификация"}])
|
||||||
|
out = _smart_extract(ej)
|
||||||
|
assert "Спецификация" in out
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert _smart_extract(None) == ""
|
||||||
|
assert _smart_extract("") == ""
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Unit-тесты db/connection.py — SQL-конвертация (без БД)."""
|
||||||
|
from db.connection import _pg_to_sqlite, _extract_table
|
||||||
|
|
||||||
|
|
||||||
|
class TestPgToSqlite:
|
||||||
|
def test_placeholder(self):
|
||||||
|
assert _pg_to_sqlite("SELECT * FROM x WHERE a = %s") == "SELECT * FROM x WHERE a = ?"
|
||||||
|
|
||||||
|
def test_jsonb(self):
|
||||||
|
out = _pg_to_sqlite("UPDATE t SET j = %s::jsonb WHERE id = %s")
|
||||||
|
assert "::jsonb" not in out
|
||||||
|
|
||||||
|
def test_boolean(self):
|
||||||
|
# " BOOLEAN " заменяется только когда после слова идёт пробел
|
||||||
|
out = _pg_to_sqlite("CREATE TABLE t (flag BOOLEAN NOT NULL)")
|
||||||
|
assert "BOOLEAN" not in out
|
||||||
|
assert "INTEGER" in out
|
||||||
|
|
||||||
|
def test_ilike(self):
|
||||||
|
assert _pg_to_sqlite("WHERE name ILIKE 'x'") == "WHERE name LIKE 'x'"
|
||||||
|
|
||||||
|
def test_true_false(self):
|
||||||
|
out = _pg_to_sqlite("SET a = TRUE, b = FALSE")
|
||||||
|
assert "TRUE" not in out
|
||||||
|
assert "FALSE" not in out
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractTable:
|
||||||
|
def test_insert(self):
|
||||||
|
assert _extract_table("INSERT INTO documents (id) VALUES (1)") == "documents"
|
||||||
|
|
||||||
|
def test_insert_no_space(self):
|
||||||
|
assert _extract_table("INSERT INTO prompts(id) VALUES (1)") == "prompts"
|
||||||
|
|
||||||
|
def test_no_insert(self):
|
||||||
|
assert _extract_table("SELECT * FROM documents") is None
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Unit-тесты services/grouping.py — нормализация + группировка (mock db)."""
|
||||||
|
from services import grouping
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeNumber:
|
||||||
|
def test_basic(self):
|
||||||
|
assert grouping.normalize_number("МЭС-123-2024") == "МЭС1232024"
|
||||||
|
|
||||||
|
def test_spaces_slashes(self):
|
||||||
|
assert grouping.normalize_number("МЭС 123/2024") == "МЭС1232024"
|
||||||
|
|
||||||
|
def test_case(self):
|
||||||
|
assert grouping.normalize_number("мэс-123") == "МЭС123"
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert grouping.normalize_number("") == ""
|
||||||
|
assert grouping.normalize_number(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDocs:
|
||||||
|
def __init__(self, docs):
|
||||||
|
self.docs = docs
|
||||||
|
|
||||||
|
def list_by_batch(self, batch_id):
|
||||||
|
return self.docs
|
||||||
|
|
||||||
|
|
||||||
|
class TestGroupDocuments:
|
||||||
|
def _doc(self, id, doc_type, own, parent, date, cp):
|
||||||
|
return {
|
||||||
|
"id": id, "filename": f"{id}.docx", "doc_type": doc_type,
|
||||||
|
"own_number": own, "parent_number": parent, "doc_date": date,
|
||||||
|
"counterparty": cp, "classify_status": "classified",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_contract_plus_supplement(self, monkeypatch):
|
||||||
|
docs = [
|
||||||
|
self._doc("d1", "contract", "03700_1", None, "2025-01-01", "ЗАО X"),
|
||||||
|
self._doc("d2", "supplement", "1", "03700_1", "2025-02-01", "ЗАО X"),
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(grouping, "db_docs", _FakeDocs(docs))
|
||||||
|
r = grouping.group_documents("b1")
|
||||||
|
assert r["ok"] is True
|
||||||
|
assert r["total_docs"] == 2
|
||||||
|
groups = [g for g in r["groups"] if g["contract_number"] != "__unresolved__"]
|
||||||
|
assert len(groups) == 1
|
||||||
|
assert groups[0]["contract_number"] == "03700_1"
|
||||||
|
assert len(groups[0]["documents"]) == 2
|
||||||
|
|
||||||
|
def test_unmatched_goes_unresolved(self, monkeypatch):
|
||||||
|
docs = [self._doc("d1", "other", None, None, None, "")]
|
||||||
|
monkeypatch.setattr(grouping, "db_docs", _FakeDocs(docs))
|
||||||
|
r = grouping.group_documents("b1")
|
||||||
|
unresolved = [g for g in r["groups"] if g["contract_number"] == "__unresolved__"]
|
||||||
|
assert len(unresolved) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyGroups:
|
||||||
|
def test_apply(self, monkeypatch):
|
||||||
|
created = []
|
||||||
|
|
||||||
|
class FakeContracts:
|
||||||
|
def insert(self, number, client=""):
|
||||||
|
return {"id": f"c_{number}"}
|
||||||
|
|
||||||
|
class FakeSupps:
|
||||||
|
def insert(self, cid, did, stype):
|
||||||
|
created.append((cid, did, stype))
|
||||||
|
return {"id": "s"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(grouping, "db_contracts", FakeContracts())
|
||||||
|
monkeypatch.setattr(grouping, "db_supplements", FakeSupps())
|
||||||
|
|
||||||
|
groups = [
|
||||||
|
{"contract_number": "03700_1", "counterparty": "X",
|
||||||
|
"documents": [{"id": "d1"}, {"id": "d2"}]},
|
||||||
|
{"contract_number": "__unresolved__", "counterparty": "",
|
||||||
|
"documents": [{"id": "d3"}]},
|
||||||
|
]
|
||||||
|
r = grouping.apply_groups("b1", groups)
|
||||||
|
assert r["ok"] is True
|
||||||
|
assert r["created"] == 2
|
||||||
|
assert created == [("c_03700_1", "d1", "initial"), ("c_03700_1", "d2", "additional")]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Unit-тесты services/llm.py — call_llm с FakeLLMClient."""
|
||||||
|
from services.llm import call_llm
|
||||||
|
from services.llm_client import FakeLLMClient
|
||||||
|
|
||||||
|
|
||||||
|
def _build(cur, txt):
|
||||||
|
return (f"prompt:{txt}", "pid-1")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCallLLM:
|
||||||
|
def test_plain_json(self):
|
||||||
|
llm = FakeLLMClient({"default": '{"mode":"partial","ops":[]}'})
|
||||||
|
res, pid = call_llm([], "текст", _build, llm_client=llm)
|
||||||
|
assert pid == "pid-1"
|
||||||
|
assert res == {"mode": "partial", "ops": []}
|
||||||
|
|
||||||
|
def test_markdown_json(self):
|
||||||
|
llm = FakeLLMClient({"default": '```json\n{"mode":"full_replace","ops":[]}\n```'})
|
||||||
|
res, _ = call_llm([], "текст", _build, llm_client=llm)
|
||||||
|
assert res["mode"] == "full_replace"
|
||||||
|
|
||||||
|
def test_markdown_generic(self):
|
||||||
|
llm = FakeLLMClient({"default": '```\n{"a":1}\n```'})
|
||||||
|
res, _ = call_llm([], "текст", _build, llm_client=llm)
|
||||||
|
assert res == {"a": 1}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Unit-тесты services/llm_client.py."""
|
||||||
|
from services.llm_client import FakeLLMClient, HttpxLLMClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestFakeLLMClient:
|
||||||
|
def test_exact_match(self):
|
||||||
|
c = FakeLLMClient({"hello": "world"})
|
||||||
|
assert c.complete("hello") == "world"
|
||||||
|
|
||||||
|
def test_partial_match(self):
|
||||||
|
c = FakeLLMClient({"needle": "found"})
|
||||||
|
assert c.complete("a needle in haystack") == "found"
|
||||||
|
|
||||||
|
def test_default_fallback(self):
|
||||||
|
c = FakeLLMClient({"default": "fallback"})
|
||||||
|
assert c.complete("whatever") == "fallback"
|
||||||
|
|
||||||
|
def test_calls_recorded(self):
|
||||||
|
c = FakeLLMClient()
|
||||||
|
c.complete("x")
|
||||||
|
c.complete("y")
|
||||||
|
assert c.calls == ["x", "y"]
|
||||||
|
|
||||||
|
def test_add(self):
|
||||||
|
c = FakeLLMClient()
|
||||||
|
c.add("k", "v")
|
||||||
|
assert c.complete("k") == "v"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpxLLMClient:
|
||||||
|
def test_init_defaults(self):
|
||||||
|
c = HttpxLLMClient(url="http://x", key="k")
|
||||||
|
assert c.model == "gpt-oss-120b"
|
||||||
|
assert c.timeout == 120
|
||||||
|
assert c.max_tokens == 8000
|
||||||
|
|
||||||
|
def test_init_custom(self):
|
||||||
|
c = HttpxLLMClient(url="http://x", key="k", model="m", timeout=10)
|
||||||
|
assert c.model == "m"
|
||||||
|
assert c.timeout == 10
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Unit-тесты services/metrics.py — чистая логика, без БД."""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.metrics import (
|
||||||
|
check_arithmetic,
|
||||||
|
normalize_date,
|
||||||
|
_to_decimal,
|
||||||
|
ClassifyMetrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeDate:
|
||||||
|
def test_dd_mm_yyyy(self):
|
||||||
|
assert normalize_date("01.01.2025") == "2025-01-01"
|
||||||
|
|
||||||
|
def test_already_iso(self):
|
||||||
|
assert normalize_date("2025-01-01") == "2025-01-01"
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert normalize_date("") is None
|
||||||
|
assert normalize_date(None) is None
|
||||||
|
|
||||||
|
def test_unrecognized_passthrough(self):
|
||||||
|
assert normalize_date("01 января 2025") == "01 января 2025"
|
||||||
|
|
||||||
|
|
||||||
|
class TestToDecimal:
|
||||||
|
def test_int(self):
|
||||||
|
assert _to_decimal("50000") == Decimal("50000")
|
||||||
|
|
||||||
|
def test_russian_number(self):
|
||||||
|
assert _to_decimal("50 000,00") == Decimal("50000.00")
|
||||||
|
|
||||||
|
def test_nbsp(self):
|
||||||
|
assert _to_decimal("1\u00a0000,50") == Decimal("1000.50")
|
||||||
|
|
||||||
|
def test_none(self):
|
||||||
|
assert _to_decimal(None) is None
|
||||||
|
assert _to_decimal("") is None
|
||||||
|
|
||||||
|
def test_garbage(self):
|
||||||
|
assert _to_decimal("не число") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckArithmetic:
|
||||||
|
def test_ok(self):
|
||||||
|
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "qty": 2, "sum": 200}}]
|
||||||
|
assert check_arithmetic(ops) == []
|
||||||
|
|
||||||
|
def test_mismatch(self):
|
||||||
|
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "qty": 2, "sum": 250}}]
|
||||||
|
m = check_arithmetic(ops)
|
||||||
|
assert len(m) == 1
|
||||||
|
assert m[0]["expected_sum"] == 200.0
|
||||||
|
assert m[0]["actual_sum"] == 250.0
|
||||||
|
assert m[0]["diff"] == 50.0
|
||||||
|
|
||||||
|
def test_update_values(self):
|
||||||
|
ops = [{"action": "UPDATE", "new_values": {"price": 10, "qty": 3, "sum": 30}}]
|
||||||
|
assert check_arithmetic(ops) == []
|
||||||
|
|
||||||
|
def test_skip_incomplete(self):
|
||||||
|
# нет qty → пропуск
|
||||||
|
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "sum": 200}}]
|
||||||
|
assert check_arithmetic(ops) == []
|
||||||
|
|
||||||
|
def test_skip_non_add_update(self):
|
||||||
|
ops = [{"action": "DELETE", "target_hash": "h"}]
|
||||||
|
assert check_arithmetic(ops) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifyMetrics:
|
||||||
|
def test_fix_rate(self):
|
||||||
|
m = ClassifyMetrics()
|
||||||
|
m.record(False)
|
||||||
|
m.record(True)
|
||||||
|
m.record(False)
|
||||||
|
assert m.total == 3
|
||||||
|
assert m.fixes == 1
|
||||||
|
assert m.fix_rate == pytest.approx(1 / 3)
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
m = ClassifyMetrics()
|
||||||
|
assert m.fix_rate == 0.0
|
||||||
|
assert m.summary()["total_classifications"] == 0
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Unit-тесты services/parse.py."""
|
||||||
|
from services.parse import parse_file, _parse_text
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseText:
|
||||||
|
def test_plain(self):
|
||||||
|
r = parse_file("test.txt", "строка1\nстрока2\nстрока3".encode())
|
||||||
|
assert r["status"] == "parsed"
|
||||||
|
assert r["element_count"] == 3
|
||||||
|
|
||||||
|
def test_text_fn(self):
|
||||||
|
r = _parse_text(b"a\nb\nc")
|
||||||
|
assert r["status"] == "parsed"
|
||||||
|
assert r["element_count"] == 3
|
||||||
|
assert r["elements"][0]["text"] == "a"
|
||||||
|
|
||||||
|
def test_no_extension_falls_back_to_text(self):
|
||||||
|
r = parse_file("noext", b"line1\nline2")
|
||||||
|
assert r["status"] == "parsed"
|
||||||
|
assert r["element_count"] == 2
|
||||||
|
|
||||||
|
def test_docx_invalid_returns_error(self):
|
||||||
|
r = parse_file("test.docx", b"not a real docx")
|
||||||
|
assert r["status"] == "error"
|
||||||
|
|
||||||
|
def test_pdf_invalid_returns_error(self):
|
||||||
|
r = parse_file("test.pdf", b"not a real pdf")
|
||||||
|
assert r["status"] == "error"
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Интеграционные тесты services/process.py — run_pipeline с Fake LLM."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from db import contracts, documents, supplements, spec_current
|
||||||
|
from db.connection import query
|
||||||
|
from services import process
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_contract(n_supps=2):
|
||||||
|
"""Создать contract + n документов (parsed) + n supplements."""
|
||||||
|
c = contracts.insert("03700_1")
|
||||||
|
for i in range(n_supps):
|
||||||
|
d = documents.insert(f"d{i}.docx", "application/octet-stream", "raw", batch_id="b1")
|
||||||
|
documents.set_parsed(d["id"], [{"type": "paragraph", "text": f"строка {i}"}])
|
||||||
|
supplements.insert(c["id"], d["id"], "initial" if i == 0 else "additional")
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
class TestFullReplace:
|
||||||
|
def test_no_duplicates(self, db, monkeypatch):
|
||||||
|
c = _seed_contract(2)
|
||||||
|
|
||||||
|
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
|
||||||
|
ops = [
|
||||||
|
{"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000}},
|
||||||
|
{"action": "ADD", "new_row": {"name": "IP-адрес IPv4", "price": 300, "qty": 8, "sum": 2400}},
|
||||||
|
{"action": "ADD", "new_row": {"name": "Канал 1 Гбит/с", "price": 20000, "qty": 1, "sum": 20000}},
|
||||||
|
]
|
||||||
|
return ({"mode": "full_replace", "ops": ops}, "pid")
|
||||||
|
|
||||||
|
monkeypatch.setattr("services.llm.call_llm", fake_call)
|
||||||
|
|
||||||
|
events = list(process.run_pipeline(c["id"], "", lambda cur, txt: ("p", "pid")))
|
||||||
|
|
||||||
|
rows = spec_current.list_by_contract(c["id"])
|
||||||
|
# 2 full_replace, но без дублей — всегда 3 строки, не 6
|
||||||
|
assert len(rows) == 3
|
||||||
|
assert {r["name"] for r in rows} == {"Аренда стойко-места", "IP-адрес IPv4", "Канал 1 Гбит/с"}
|
||||||
|
|
||||||
|
# история сохранена: 2 full_replace × 3 ADD = 6 событий
|
||||||
|
ev = query("SELECT count(*) AS c FROM spec_events WHERE contract_id = %s", (c["id"],))
|
||||||
|
assert ev[0]["c"] == 6
|
||||||
|
|
||||||
|
# не было ошибок извлечения
|
||||||
|
assert not any(e.get("type") == "extract_error" for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTargetIdTranslation:
|
||||||
|
def test_update_applies(self, db, monkeypatch):
|
||||||
|
c = _seed_contract(2)
|
||||||
|
|
||||||
|
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
|
||||||
|
if not current_spec:
|
||||||
|
return ({"mode": "partial", "ops": [
|
||||||
|
{"action": "ADD", "new_row": {"name": "Аренда", "price": 50000, "qty": 1, "sum": 50000}},
|
||||||
|
]}, "pid")
|
||||||
|
return ({"mode": "partial", "ops": [
|
||||||
|
{"action": "UPDATE", "target_id": "r1", "new_values": {"price": 55000, "sum": 55000}},
|
||||||
|
]}, "pid")
|
||||||
|
|
||||||
|
monkeypatch.setattr("services.llm.call_llm", fake_call)
|
||||||
|
|
||||||
|
list(process.run_pipeline(c["id"], "", lambda cur, txt: ("p", "pid")))
|
||||||
|
|
||||||
|
rows = spec_current.list_by_contract(c["id"])
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["price"] == 55000 # UPDATE применился, а не ушёл в UNRESOLVED
|
||||||
|
|
||||||
|
upd = query("SELECT status FROM spec_events WHERE contract_id = %s AND action = 'UPDATE'", (c["id"],))
|
||||||
|
assert upd[0]["status"] == "applied"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoSupplements:
|
||||||
|
def test_error_event(self, db):
|
||||||
|
evs = list(process.run_pipeline("nonexistent", "", lambda cur, txt: ("p", "pid")))
|
||||||
|
assert any(e.get("type") == "error" for e in evs)
|
||||||
|
|
||||||
|
|
||||||
|
class TestElementsToText:
|
||||||
|
def test_list(self):
|
||||||
|
ej = [
|
||||||
|
{"type": "paragraph", "text": "Привет"},
|
||||||
|
{"type": "table", "rows": [["a", "b"], ["c", "d"]]},
|
||||||
|
]
|
||||||
|
assert process._elements_to_text(ej) == "Привет\na | b\nc | d"
|
||||||
|
|
||||||
|
def test_dict_value(self):
|
||||||
|
ej = {"Value": [{"type": "paragraph", "text": "X"}]}
|
||||||
|
assert process._elements_to_text(ej) == "X"
|
||||||
|
|
||||||
|
def test_string_json(self):
|
||||||
|
ej = json.dumps([{"type": "paragraph", "text": "Y"}])
|
||||||
|
assert process._elements_to_text(ej) == "Y"
|
||||||
|
|
||||||
|
def test_plain_string(self):
|
||||||
|
assert process._elements_to_text("просто текст") == "просто текст"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Интеграционные тесты HTTP-эндпоинтов (Flask test client)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
from db import connection as conn
|
||||||
|
monkeypatch.setattr(conn, "DB_PATH", str(tmp_path / "app.db"))
|
||||||
|
from app import create_app
|
||||||
|
app = create_app()
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
yield app.test_client()
|
||||||
|
c = getattr(conn._local, "conn", None)
|
||||||
|
if c is not None:
|
||||||
|
try:
|
||||||
|
c.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn._local.conn = None
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealth:
|
||||||
|
def test_health(self, client):
|
||||||
|
from config import VERSION
|
||||||
|
r = client.get("/health")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["version"] == VERSION
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpecCurrent:
|
||||||
|
def test_missing_contract_id(self, client):
|
||||||
|
r = client.get("/api/spec-current")
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_empty(self, client):
|
||||||
|
r = client.get("/api/spec-current?contract_id=missing")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["rows"] == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestGroups:
|
||||||
|
def test_missing_batch(self, client):
|
||||||
|
r = client.get("/api/groups")
|
||||||
|
assert r.status_code == 400
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Интеграционные тесты db/spec_current.py."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from db import spec_current, documents, spec_events
|
||||||
|
|
||||||
|
|
||||||
|
class TestListByContract:
|
||||||
|
def test_empty(self, db):
|
||||||
|
assert spec_current.list_by_contract("nope") == []
|
||||||
|
|
||||||
|
def test_ordered_by_name(self, db):
|
||||||
|
ops = [
|
||||||
|
{"action": "ADD", "new_row": {"name": "Б", "price": 2}},
|
||||||
|
{"action": "ADD", "new_row": {"name": "А", "price": 1}},
|
||||||
|
]
|
||||||
|
spec_events.apply_ops("c1", "s1", "d1", ops, "pid", {})
|
||||||
|
rows = spec_current.list_by_contract("c1")
|
||||||
|
assert [r["name"] for r in rows] == ["А", "Б"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetElementsJson:
|
||||||
|
def test_none(self, db):
|
||||||
|
assert spec_current.get_elements_json("missing") is None
|
||||||
|
|
||||||
|
def test_parsed(self, db):
|
||||||
|
d = documents.insert("f.docx", "m", "raw", batch_id="b1")
|
||||||
|
documents.set_parsed(d["id"], [{"type": "paragraph", "text": "x"}])
|
||||||
|
ej = spec_current.get_elements_json(d["id"])
|
||||||
|
assert ej is not None
|
||||||
|
parsed = json.loads(ej)
|
||||||
|
assert parsed[0]["text"] == "x"
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Интеграционные тесты db/spec_events.py (SQLite, изолированная БД)."""
|
||||||
|
from db.connection import query
|
||||||
|
from db import spec_events, spec_current
|
||||||
|
|
||||||
|
|
||||||
|
CID = "11111111-1111-1111-1111-111111111111"
|
||||||
|
SID = "22222222-2222-2222-2222-222222222222"
|
||||||
|
DID = "33333333-3333-3333-3333-333333333333"
|
||||||
|
|
||||||
|
|
||||||
|
def _count(table, contract_id):
|
||||||
|
rows = query(f"SELECT count(*) AS c FROM {table} WHERE contract_id = %s", (contract_id,))
|
||||||
|
return rows[0]["c"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyOpsAdd:
|
||||||
|
def test_add(self, db):
|
||||||
|
ops = [{"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2025-01-01"}}]
|
||||||
|
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
||||||
|
assert s == {"added": 1, "updated": 0, "deleted": 0, "unresolved": 0}
|
||||||
|
rows = spec_current.list_by_contract(CID)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["name"] == "Аренда стойко-места"
|
||||||
|
assert rows[0]["price"] == 50000
|
||||||
|
assert rows[0]["date_start"] == "2025-01-01"
|
||||||
|
|
||||||
|
def test_add_empty_name_unresolved(self, db):
|
||||||
|
ops = [{"action": "ADD", "new_row": {"name": ""}}]
|
||||||
|
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
||||||
|
assert s == {"added": 0, "updated": 0, "deleted": 0, "unresolved": 1}
|
||||||
|
assert spec_current.list_by_contract(CID) == []
|
||||||
|
|
||||||
|
def test_add_multiple(self, db):
|
||||||
|
ops = [
|
||||||
|
{"action": "ADD", "new_row": {"name": "A", "price": 1}},
|
||||||
|
{"action": "ADD", "new_row": {"name": "B", "price": 2}},
|
||||||
|
]
|
||||||
|
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
||||||
|
assert s["added"] == 2
|
||||||
|
assert len(spec_current.list_by_contract(CID)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyOpsUpdateDelete:
|
||||||
|
def test_update_and_delete(self, db):
|
||||||
|
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "Аренда", "price": 50000, "qty": 1, "sum": 50000}}], "pid", {})
|
||||||
|
h = spec_events._hash("Аренда")
|
||||||
|
|
||||||
|
s = spec_events.apply_ops(CID, SID, DID, [{"action": "UPDATE", "target_hash": h, "new_values": {"price": 55000}}], "pid", {})
|
||||||
|
assert s["updated"] == 1
|
||||||
|
assert spec_current.list_by_contract(CID)[0]["price"] == 55000
|
||||||
|
|
||||||
|
s = spec_events.apply_ops(CID, SID, DID, [{"action": "DELETE", "target_hash": h}], "pid", {})
|
||||||
|
assert s["deleted"] == 1
|
||||||
|
assert spec_current.list_by_contract(CID) == []
|
||||||
|
|
||||||
|
def test_update_empty_hash_unresolved(self, db):
|
||||||
|
s = spec_events.apply_ops(CID, SID, DID, [{"action": "UPDATE", "new_values": {"price": 1}}], "pid", {})
|
||||||
|
assert s["updated"] == 0
|
||||||
|
rows = query("SELECT status FROM spec_events WHERE contract_id = %s", (CID,))
|
||||||
|
assert rows[0]["status"] == "unresolved"
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnresolvedAndUnknown:
|
||||||
|
def test_unresolved_action(self, db):
|
||||||
|
ops = [{"action": "UNRESOLVED", "new_values": {"name": "X"}, "reason": "нет соответствия"}]
|
||||||
|
spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
||||||
|
assert spec_current.list_by_contract(CID) == []
|
||||||
|
rows = query("SELECT action, status FROM spec_events WHERE contract_id = %s", (CID,))
|
||||||
|
assert rows[0]["action"] == "UNRESOLVED"
|
||||||
|
assert rows[0]["status"] == "unresolved"
|
||||||
|
|
||||||
|
def test_unknown_action(self, db):
|
||||||
|
spec_events.apply_ops(CID, SID, DID, [{"action": "WHATEVER", "new_row": {"name": "X"}}], "pid", {})
|
||||||
|
assert spec_current.list_by_contract(CID) == []
|
||||||
|
rows = query("SELECT action FROM spec_events WHERE contract_id = %s", (CID,))
|
||||||
|
assert rows[0]["action"] == "UNRESOLVED"
|
||||||
|
|
||||||
|
|
||||||
|
class TestClearAndReset:
|
||||||
|
def test_clear_current_keeps_events(self, db):
|
||||||
|
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A", "price": 1}}], "pid", {})
|
||||||
|
assert _count("spec_current", CID) == 1
|
||||||
|
spec_events.clear_current(CID)
|
||||||
|
assert _count("spec_current", CID) == 0
|
||||||
|
assert _count("spec_events", CID) == 1 # история сохранена
|
||||||
|
|
||||||
|
def test_reset_clears_both(self, db):
|
||||||
|
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A", "price": 1}}], "pid", {})
|
||||||
|
spec_events.reset(CID)
|
||||||
|
assert _count("spec_current", CID) == 0
|
||||||
|
assert _count("spec_events", CID) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestHashAndSeq:
|
||||||
|
def test_hash_normalizes(self):
|
||||||
|
assert spec_events._hash(" Арена стойко-места ") == spec_events._hash("арена стойко-места")
|
||||||
|
|
||||||
|
def test_hash_includes_date(self):
|
||||||
|
assert spec_events._hash("Аренда", "01.01.2025") != spec_events._hash("Аренда", "01.02.2025")
|
||||||
|
|
||||||
|
def test_hash_len(self):
|
||||||
|
assert len(spec_events._hash("x")) == 16
|
||||||
|
|
||||||
|
def test_seq(self, db):
|
||||||
|
assert spec_events.get_next_seq(CID) == 1
|
||||||
|
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A"}}], "pid", {})
|
||||||
|
assert spec_events.get_next_seq(CID) == 2
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# upload — переиспользуемые слои загрузки через ВМ
|
||||||
|
|
||||||
|
Два самодостаточных слоя для выноса в любой другой проект БЕЗ изменения кода
|
||||||
|
(меняется только конфиг). Поведение 1:1 с drhider v0.0.75.
|
||||||
|
|
||||||
|
```
|
||||||
|
upload/
|
||||||
|
frontend/
|
||||||
|
zip/ # распаковка ZIP (чистые функции)
|
||||||
|
table/ # слой 1: выбор файлов/папки/архива, дедуп, статусы, таблица
|
||||||
|
upload/ # слой 2 (фронт): PUT на ВМ + POST /api/upload_refs
|
||||||
|
backend/
|
||||||
|
upload_refs/ # слой 2 (бэк): Blueprint upload_refs (SSRF, _safe_name, ретраи)
|
||||||
|
session/ # in-memory сессия с TTL и лимитами
|
||||||
|
config.example.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
| Слой | Где | Ответственность |
|
||||||
|
|---|---|---|
|
||||||
|
| **1. Выбор файлов** | фронт | таблица, дедуп, раскрытие ZIP, путь, статусы, кнопки |
|
||||||
|
| **2. Закачка через ВМ** | фронт + бэк | PUT на ВМ-буфер (фронт) → `upload_refs` pull (бэк) → сессия |
|
||||||
|
| **3. Логика приложения** | — | у каждого приложения своя (обфускация, SSE и т.п.). В модуле её НЕТ |
|
||||||
|
|
||||||
|
Паттерн (зачем ВМ): шлюз managed-кластера рвёт тела >64КБ, egress не ограничен.
|
||||||
|
Поэтому: браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` → egress `GET` → сессия.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Подключение фронта
|
||||||
|
|
||||||
|
Подключить ES-модули (`<script type="module">`) и собрать слой 1:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { initUploadTable } from './upload/frontend/table/init_upload_table.js';
|
||||||
|
import { uploadViaVM } from './upload/frontend/upload/upload_via_vm.js';
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
allowedExt: ['.pdf', '.doc', '.docx', '.txt', '.md'],
|
||||||
|
maxFileBytes: 50 * 1024 * 1024,
|
||||||
|
maxSessionBytes: 500 * 1024 * 1024,
|
||||||
|
estMbSec: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const table = initUploadTable(cfg, {
|
||||||
|
fileInput: document.getElementById('fileInput'), // <input type="file" multiple>
|
||||||
|
folderInput: document.getElementById('folderInput'), // <input webkitdirectory>
|
||||||
|
tableBody: document.getElementById('fileList'), // <tbody>
|
||||||
|
countEl: document.getElementById('fileCount'),
|
||||||
|
uploadBtnEl: document.getElementById('uploadBtn'),
|
||||||
|
onStatus(cls, text) { /* сообщения (cls: ''|'progress'|'done'|'error') */ },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Слой 2 — закачка учитываемых файлов на ВМ:
|
||||||
|
// idxInSf[k] — индекс k-го отправляемого файла в строках таблицы (своя логика маппинга)
|
||||||
|
const res = await uploadViaVM(toSend, cfg.vmUploadUrl, {
|
||||||
|
session: currentSid,
|
||||||
|
onStatus(k, html) { table.setStatus(idxInSf[k], html); }, // прогресс → ячейка таблицы
|
||||||
|
onUploadStatus(text) { /* статусная строка */ },
|
||||||
|
onXHR(xhr) { activeXHR = xhr; }, // регистрация активного PUT для отмены (abort)
|
||||||
|
});
|
||||||
|
// res = {ok:true, session, count} | {ok:false, error}
|
||||||
|
// После этого у вас в сессии res.session лежат файлы — запускайте СВОЮ обработку.
|
||||||
|
```
|
||||||
|
|
||||||
|
API слоя 1 (`initUploadTable(cfg, els)` → `table`):
|
||||||
|
- `addFiles(File[])` — дедуп + раскрытие ZIP + фильтр + лимиты;
|
||||||
|
- `getFiles()` → `[{name, size, file}]` — **только учитываемые** (без сверхлимитных);
|
||||||
|
- `getOverNames()` → `Set` — имена сверх лимита;
|
||||||
|
- `setStatus(idx, html)` — статус в ячейке таблицы;
|
||||||
|
- `render()` — перерисовать таблицу;
|
||||||
|
- `clear()` — очистить список;
|
||||||
|
- `setBusy(bool)` — заблокировать список на время загрузки/обработки;
|
||||||
|
- `state` — доступ к состоянию (для слоя 3: установить `state.proc` для 3-секционной таблицы).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Подключение бэка
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Flask
|
||||||
|
from upload.backend.upload_refs import create_upload_refs_blueprint
|
||||||
|
from upload.backend.session import create_session, add_file, get_files
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(create_upload_refs_blueprint({
|
||||||
|
"apiPrefix": "/api", # префикс эндпоинтов
|
||||||
|
"vmUploadPrefix": "https://.../drhider-upload/", # доверенный префикс (SSRF)
|
||||||
|
"maxFileBytes": 50 * 1024 * 1024,
|
||||||
|
"maxSessionBytes": 500 * 1024 * 1024,
|
||||||
|
"ttlSeconds": 1800,
|
||||||
|
"pullRetries": 3,
|
||||||
|
"pullRetryDelay": 2,
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
Эндпоинт: `POST {apiPrefix}/upload_refs` — принимает JSON
|
||||||
|
`{"session": "...", "files": [{"name", "size", "url"}]}`, тянет каждый файл с ВМ
|
||||||
|
(SSRF-валидация по `vmUploadPrefix`, `_safe_name`, ретраи), кладёт в сессию.
|
||||||
|
Возвращает `{"ok": true, "session", "count"}`.
|
||||||
|
|
||||||
|
Сессия: `create_session()` → sid; `add_file(sid, name, content)` (лимит 500МБ);
|
||||||
|
`get_files(sid)` → `[(name, bytes), ...]` или `None`. TTL 30 мин (таймер в фоне).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Раздача модуля в Flask (обязательно)
|
||||||
|
|
||||||
|
Фронт-модули — ES-модули, браузер грузит их по HTTP (не через file://).
|
||||||
|
Добавьте route, который отдаёт папку `upload/frontend` (как в drhider `site/routes/main_bp.py`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# в любом blueprint приложения
|
||||||
|
import os
|
||||||
|
from flask import send_from_directory
|
||||||
|
|
||||||
|
_UPLOAD_FRONTEND = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
"upload", "frontend")
|
||||||
|
|
||||||
|
@bp.route("/upload/<path:filename>")
|
||||||
|
def upload_frontend(filename):
|
||||||
|
return send_from_directory(_UPLOAD_FRONTEND, filename)
|
||||||
|
```
|
||||||
|
|
||||||
|
Тогда импорты в браузере: `import { initUploadTable } from '/upload/table/init_upload_table.js';`.
|
||||||
|
|
||||||
|
> Если фронт-модули хостятся отдельно (другой домен/приложение) — путь `/upload/...`
|
||||||
|
> и CORS настраиваются под ваш случай (правка приложения/nginx, не кода модуля).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слой 3: как подключить обработку (опционально)
|
||||||
|
|
||||||
|
Модуль отдаёт файлы в сессию, а обрабатываете их ВЫ (обфускация, конвертация, ...).
|
||||||
|
После `uploadViaVM` файлы лежат в `res.session`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
files = get_files(res.session) # [(name, bytes), ...]
|
||||||
|
# ... ваша обработка ...
|
||||||
|
store_result(res.session, result_zip) # если нужно отдать результат обратно
|
||||||
|
```
|
||||||
|
|
||||||
|
Для 3-секционной таблицы прогресса (готово/текущий/ожидают) модуль предоставляет
|
||||||
|
`renderProcTable` — достаточно дать слою 3 доступ к `table.state.proc`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
table.state.proc = { phase: 'processing', procState: {}, procExtractRate: null };
|
||||||
|
function syncProcCtx() {
|
||||||
|
table.state.proc.phase = phase;
|
||||||
|
table.state.proc.procState = procState; // {idx: {st, elapsed, eta, chars, t0}}
|
||||||
|
table.state.proc.procExtractRate = rate; // сек/МБ (для оценок ожидающих)
|
||||||
|
}
|
||||||
|
syncProcCtx();
|
||||||
|
table.render(); // сам выберет renderProcTable при phase==='processing'
|
||||||
|
```
|
||||||
|
|
||||||
|
Статусы в ячейках: `table.setStatus(idx, html)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Конфиг (слой 0)
|
||||||
|
|
||||||
|
Всё drhider-специфичное задаётся конфигом, а не кодом слоёв:
|
||||||
|
|
||||||
|
| Поле | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `vmUploadUrl` | базовый URL ВМ-буфера для PUT (фронт) |
|
||||||
|
| `vmUploadPrefix` | тот же префикс для SSRF-валидации (бэк) |
|
||||||
|
| `allowedExt` | расширения документов из папки/архивов |
|
||||||
|
| `maxFileBytes` / `maxSessionBytes` | лимиты 50 МБ / 500 МБ |
|
||||||
|
| `apiPrefix` | префикс Blueprint `/api` |
|
||||||
|
| `pullRetries` / `pullRetryDelay` | ретраи pull (3 × 2с) |
|
||||||
|
| `pullTimeout` | таймаут одного GET pull (сек) |
|
||||||
|
| `ttlSeconds` | TTL сессии (по умолчанию 1800) |
|
||||||
|
| `estMbSec` | оценка времени обработки, сек/МБ (только UI) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что НЕ трогать
|
||||||
|
|
||||||
|
- Слой 3 — логика приложения (обработка файлов из сессии, SSE-прогресс) у каждого своя.
|
||||||
|
- CORS на ВМ-буфере — если домен приложения другой, правится nginx на ВМ, а не код модуля.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Переиспользуемый модуль загрузки через ВМ (2 слоя).
|
||||||
|
|
||||||
|
Структура:
|
||||||
|
- frontend/ — слой 1 (выбор файлов) + слой 2 (закачка через ВМ), JS.
|
||||||
|
- backend/ — слой 2 (бэк): Blueprint upload_refs + in-memory сессия, Python.
|
||||||
|
|
||||||
|
Подключение в новый проект — см. README.md.
|
||||||
|
"""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""In-memory хранилище сессий с TTL и лимитами.
|
||||||
|
|
||||||
|
Каждая операция — в отдельном файле (см. ниже), общее состояние — в state.py.
|
||||||
|
Импорт как единый пакет:
|
||||||
|
|
||||||
|
from upload.backend.session import create_session, add_file, get_files
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .create_session import create_session
|
||||||
|
from .add_file import add_file
|
||||||
|
from .get_files import get_files, file_count
|
||||||
|
from .store_result import store_result, get_result
|
||||||
|
from .store_csv import store_csv, get_csv
|
||||||
|
from .ttl import touch, pause_ttl, resume_ttl
|
||||||
|
from .cancel import request_cancel, get_cancel_event
|
||||||
|
from .cleanup import cleanup
|
||||||
|
from .state import TTL_SECONDS, MAX_FILE_BYTES, MAX_SESSION_BYTES, configure
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"create_session",
|
||||||
|
"add_file",
|
||||||
|
"get_files",
|
||||||
|
"file_count",
|
||||||
|
"store_result",
|
||||||
|
"get_result",
|
||||||
|
"store_csv",
|
||||||
|
"get_csv",
|
||||||
|
"touch",
|
||||||
|
"pause_ttl",
|
||||||
|
"resume_ttl",
|
||||||
|
"request_cancel",
|
||||||
|
"get_cancel_event",
|
||||||
|
"cleanup",
|
||||||
|
"configure",
|
||||||
|
"TTL_SECONDS",
|
||||||
|
"MAX_FILE_BYTES",
|
||||||
|
"MAX_SESSION_BYTES",
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""add_file — добавить файл в сессию (с проверкой суммарного лимита)."""
|
||||||
|
|
||||||
|
from . import state
|
||||||
|
|
||||||
|
|
||||||
|
def add_file(sid: str, filename: str, content: bytes) -> bool:
|
||||||
|
"""Добавить файл в сессию.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: Идентификатор сессии
|
||||||
|
filename: Имя файла
|
||||||
|
content: Бинарное содержимое
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если добавлено; False если сессии нет или превышен лимит сессии.
|
||||||
|
"""
|
||||||
|
with state._lock:
|
||||||
|
s = state._sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
total = sum(len(c) for _, c in s["files"])
|
||||||
|
if total + len(content) > state.MAX_SESSION_BYTES:
|
||||||
|
return False # превышен суммарный лимит сессии
|
||||||
|
s["files"].append((filename, content))
|
||||||
|
return True
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""request_cancel / get_cancel_event — мягкое прерывание обработки сессии."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .state import _sessions, _lock
|
||||||
|
|
||||||
|
|
||||||
|
def request_cancel(sid: str) -> bool:
|
||||||
|
"""Запросить мягкое прерывание обработки сессии.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если сессия существует и отмена запрошена, False если нет.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
s["cancel"].set()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_cancel_event(sid: str) -> Optional[threading.Event]:
|
||||||
|
"""Получить событие отмены сессии (или None, если сессии нет)."""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
return s["cancel"] if s else None
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""cleanup — удалить сессию."""
|
||||||
|
|
||||||
|
from .state import _sessions, _lock
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup(sid: str):
|
||||||
|
"""Удалить сессию и остановить её TTL-таймер."""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.pop(sid, None)
|
||||||
|
if s and s.get("timer"):
|
||||||
|
s["timer"].cancel()
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""create_session — создать новую сессию."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from .state import _sessions, _lock, _start_timer
|
||||||
|
|
||||||
|
|
||||||
|
def create_session() -> str:
|
||||||
|
"""Создать новую сессию.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Уникальный идентификатор сессии (UUID).
|
||||||
|
"""
|
||||||
|
sid = uuid.uuid4().hex
|
||||||
|
with _lock:
|
||||||
|
_sessions[sid] = {
|
||||||
|
"files": [],
|
||||||
|
"result": None,
|
||||||
|
"cancel": threading.Event(),
|
||||||
|
"timer": _start_timer(sid),
|
||||||
|
}
|
||||||
|
return sid
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""get_files / file_count — чтение файлов сессии."""
|
||||||
|
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from .state import _sessions, _lock
|
||||||
|
|
||||||
|
|
||||||
|
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
|
||||||
|
"""Получить все файлы сессии.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[(filename, content), ...] или None если сессия не найдена.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
return list(s["files"]) if s else None
|
||||||
|
|
||||||
|
|
||||||
|
def file_count(sid: str) -> int:
|
||||||
|
"""Количество файлов в сессии."""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
return len(s["files"]) if s else 0
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Общее состояние сессий: хранилище, блокировка, константы, TTL-таймер.
|
||||||
|
|
||||||
|
Единая точка хранения состояния — все операции импортируют её.
|
||||||
|
Дробить state.py на файл-на-переменную не нужно: это данные, а не функции.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
# TTL сессии: 30 минут
|
||||||
|
TTL_SECONDS = 30 * 60
|
||||||
|
|
||||||
|
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
|
||||||
|
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
|
||||||
|
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
|
||||||
|
|
||||||
|
_sessions: dict = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _start_timer(sid: str) -> threading.Timer:
|
||||||
|
"""Запустить таймер автоочистки сессии через TTL."""
|
||||||
|
|
||||||
|
def _clean():
|
||||||
|
with _lock:
|
||||||
|
_sessions.pop(sid, None)
|
||||||
|
|
||||||
|
timer = threading.Timer(TTL_SECONDS, _clean)
|
||||||
|
timer.daemon = True
|
||||||
|
timer.start()
|
||||||
|
return timer
|
||||||
|
|
||||||
|
|
||||||
|
def configure(max_file_bytes: int = None, max_session_bytes: int = None,
|
||||||
|
ttl_seconds: int = None):
|
||||||
|
"""Переопределить лимиты/TTL из конфига приложения (глобально).
|
||||||
|
|
||||||
|
None — оставить текущее значение.
|
||||||
|
"""
|
||||||
|
global MAX_FILE_BYTES, MAX_SESSION_BYTES, TTL_SECONDS
|
||||||
|
if max_file_bytes is not None:
|
||||||
|
MAX_FILE_BYTES = max_file_bytes
|
||||||
|
if max_session_bytes is not None:
|
||||||
|
MAX_SESSION_BYTES = max_session_bytes
|
||||||
|
if ttl_seconds is not None:
|
||||||
|
TTL_SECONDS = ttl_seconds
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""store_csv / get_csv — сохранение и чтение CSV с таблицей замен."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .state import _sessions, _lock
|
||||||
|
|
||||||
|
|
||||||
|
def store_csv(sid: str, csv_str: str) -> bool:
|
||||||
|
"""Сохранить CSV с таблицей замен.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если сохранено, False если сессии нет.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
s["csv"] = csv_str
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_csv(sid: str) -> Optional[str]:
|
||||||
|
"""Получить CSV с таблицей замен.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Строка CSV или None если нет.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
return s.get("csv") if s else None
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""store_result / get_result — сохранение и чтение результата обработки."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .state import _sessions, _lock
|
||||||
|
|
||||||
|
|
||||||
|
def store_result(sid: str, zip_data: bytes) -> bool:
|
||||||
|
"""Сохранить результат обработки (ZIP-архив).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если сохранено, False если сессии нет.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
s["result"] = zip_data
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_result(sid: str) -> Optional[bytes]:
|
||||||
|
"""Получить результат обработки.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ZIP-архив или None если сессия не найдена/результат не готов.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
return s["result"] if s else None
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""touch / pause_ttl / resume_ttl — управление TTL-таймером сессии."""
|
||||||
|
|
||||||
|
from .state import _sessions, _lock, _start_timer
|
||||||
|
|
||||||
|
|
||||||
|
def touch(sid: str):
|
||||||
|
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return
|
||||||
|
if s.get("timer"):
|
||||||
|
s["timer"].cancel()
|
||||||
|
s["timer"] = _start_timer(sid)
|
||||||
|
|
||||||
|
|
||||||
|
def pause_ttl(sid: str):
|
||||||
|
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
if s and s.get("timer"):
|
||||||
|
s["timer"].cancel()
|
||||||
|
s["timer"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def resume_ttl(sid: str):
|
||||||
|
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return
|
||||||
|
if s.get("timer"):
|
||||||
|
s["timer"].cancel()
|
||||||
|
s["timer"] = _start_timer(sid)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Слой 2 (бэк): переиспользуемый Blueprint закачки через ВМ.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
from upload.backend.upload_refs import create_upload_refs_blueprint
|
||||||
|
app.register_blueprint(create_upload_refs_blueprint(cfg))
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .blueprint import create_upload_refs_blueprint
|
||||||
|
from .safe_name import safe_name
|
||||||
|
from .pull_file import pull_file
|
||||||
|
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"create_upload_refs_blueprint",
|
||||||
|
"safe_name",
|
||||||
|
"pull_file",
|
||||||
|
"PULL_RETRIES",
|
||||||
|
"PULL_RETRY_DELAY",
|
||||||
|
"VM_UPLOAD_PREFIX",
|
||||||
|
]
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Переиспользуемый Blueprint слоя 2: POST /upload_refs (pull с ВМ-буфера в сессию).
|
||||||
|
|
||||||
|
Поведение 1:1 с drhider v0.0.75 (site/routes/api_bp.py):
|
||||||
|
- _safe_name (path traversal)
|
||||||
|
- SSRF-валидация url.startswith(VM_UPLOAD_PREFIX)
|
||||||
|
- лимит на один файл -> delete+skip
|
||||||
|
- pull с ретраями
|
||||||
|
- лимит сессии -> skip; отсутствие сессии -> 404
|
||||||
|
- delete url с ВМ (best-effort)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
|
||||||
|
from ..session import (create_session, add_file, get_files, file_count,
|
||||||
|
MAX_FILE_BYTES, configure)
|
||||||
|
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
|
||||||
|
from .safe_name import safe_name
|
||||||
|
from .pull_file import pull_file
|
||||||
|
|
||||||
|
log = logging.getLogger("upload.upload_refs")
|
||||||
|
|
||||||
|
|
||||||
|
def create_upload_refs_blueprint(cfg: dict, sink=None) -> Blueprint:
|
||||||
|
"""Создать Blueprint с эндпоинтом upload_refs.
|
||||||
|
|
||||||
|
cfg (все ключи опциональны, есть дефолты):
|
||||||
|
apiPrefix (str) — префикс Blueprint, по умолчанию "/api"
|
||||||
|
vmUploadPrefix (str) — доверенный префикс ВМ-буфера (SSRF-валидация)
|
||||||
|
maxFileBytes (int) — лимит на один файл
|
||||||
|
maxSessionBytes (int) — суммарный лимит сессии (применяется к сессиям)
|
||||||
|
ttlSeconds (int) — TTL сессии
|
||||||
|
pullRetries (int) — ретраи pull
|
||||||
|
pullRetryDelay (int) — пауза между ретраями (сек)
|
||||||
|
pullTimeout (int) — таймаут одного GET pull
|
||||||
|
|
||||||
|
sink (callable | None): если задан — вместо add_file в in-memory сессию
|
||||||
|
вызывается sink(name, content, **extra) на каждый вытянутый файл,
|
||||||
|
endpoint возвращает {"ok": true, "results": [...]}. extra — сквозные
|
||||||
|
поля тела запроса (batch_id, contract_id, zip_source). Если None —
|
||||||
|
прежнее поведение drhider (in-memory сессия).
|
||||||
|
"""
|
||||||
|
prefix = cfg.get("apiPrefix", "/api")
|
||||||
|
vm_prefix = cfg.get("vmUploadPrefix", VM_UPLOAD_PREFIX)
|
||||||
|
max_file_bytes = cfg.get("maxFileBytes", MAX_FILE_BYTES)
|
||||||
|
pull_retries = cfg.get("pullRetries", PULL_RETRIES)
|
||||||
|
pull_delay = cfg.get("pullRetryDelay", PULL_RETRY_DELAY)
|
||||||
|
pull_timeout = cfg.get("pullTimeout", 120)
|
||||||
|
|
||||||
|
# Применить лимиты сессии/TTL из конфига (глобально для всех сессий)
|
||||||
|
configure(
|
||||||
|
max_file_bytes=cfg.get("maxFileBytes"),
|
||||||
|
max_session_bytes=cfg.get("maxSessionBytes"),
|
||||||
|
ttl_seconds=cfg.get("ttlSeconds"),
|
||||||
|
)
|
||||||
|
|
||||||
|
bp = Blueprint("upload_refs", __name__, url_prefix=prefix)
|
||||||
|
|
||||||
|
@bp.route("/upload_refs", methods=["POST"])
|
||||||
|
def upload_refs():
|
||||||
|
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
|
||||||
|
|
||||||
|
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
|
||||||
|
Каждый файл тянется ИСХОДЯЩИМ GET'ом с ВМ (egress не ограничен шлюзом),
|
||||||
|
читается по частям (stream), кладётся в сессию. После успешного pull файл
|
||||||
|
удаляется с ВМ (best-effort; TTL-чистка на ВМ тоже есть).
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
sid = data.get("session") or create_session()
|
||||||
|
refs = data.get("files") or []
|
||||||
|
if not refs:
|
||||||
|
log.warning("upload_refs: no files, sid=%s", sid)
|
||||||
|
return jsonify({"ok": False, "error": "No files"}), 400
|
||||||
|
|
||||||
|
extra = {k: data[k] for k in ("batch_id", "contract_id", "zip_source") if data.get(k) is not None}
|
||||||
|
results = [] if sink else None
|
||||||
|
added = 0
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=pull_timeout, follow_redirects=True) as client:
|
||||||
|
for ref in refs:
|
||||||
|
name = safe_name(ref.get("name") or "")
|
||||||
|
url = ref.get("url")
|
||||||
|
if not name or not url:
|
||||||
|
continue
|
||||||
|
# SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера
|
||||||
|
if not url.startswith(vm_prefix):
|
||||||
|
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
|
||||||
|
if sink:
|
||||||
|
results.append({"name": name, "ok": False, "error": "invalid url (SSRF guard)"})
|
||||||
|
continue
|
||||||
|
# Лимит на один файл: сверх лимита — пропускаем (не участвует)
|
||||||
|
if (ref.get("size") or 0) > max_file_bytes:
|
||||||
|
log.warning("upload_refs: file exceeds %dMB, skip sid=%s file=%r size=%s",
|
||||||
|
max_file_bytes // (1024 * 1024), sid, name, ref.get("size"))
|
||||||
|
try:
|
||||||
|
client.delete(url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if sink:
|
||||||
|
results.append({"name": name, "ok": False, "error": "file too large"})
|
||||||
|
continue
|
||||||
|
# Pull с ретраями: разовые DNS/сетевые сбои не роняют всю загрузку
|
||||||
|
try:
|
||||||
|
content = pull_file(client, url, pull_retries, pull_delay, sid=sid, name=name)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("upload_refs: pull failed sid=%s file=%r: %r", sid, name, e)
|
||||||
|
if sink:
|
||||||
|
results.append({"name": name, "ok": False, "error": "pull failed: %s" % e})
|
||||||
|
continue
|
||||||
|
log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content))
|
||||||
|
if len(content) > max_file_bytes:
|
||||||
|
log.warning("upload_refs: pulled file exceeds %dMB, skip sid=%s file=%r size=%d",
|
||||||
|
max_file_bytes // (1024 * 1024), sid, name, len(content))
|
||||||
|
try:
|
||||||
|
client.delete(url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if sink:
|
||||||
|
results.append({"name": name, "ok": False, "error": "file too large"})
|
||||||
|
continue
|
||||||
|
if sink:
|
||||||
|
# Отдать файл приложению через sink (вместо in-memory сессии)
|
||||||
|
try:
|
||||||
|
client.delete(url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
r = sink(name, content, **extra) or {}
|
||||||
|
results.append({"name": name, **r})
|
||||||
|
except Exception as e:
|
||||||
|
log.error("upload_refs: sink failed file=%r: %r", name, e)
|
||||||
|
results.append({"name": name, "ok": False, "error": str(e)})
|
||||||
|
continue
|
||||||
|
if not add_file(sid, name, content):
|
||||||
|
# Различить: сессия исчезла vs превышен суммарный лимит сессии
|
||||||
|
if get_files(sid) is None:
|
||||||
|
log.warning("upload_refs: session not found, sid=%s file=%r", sid, name)
|
||||||
|
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||||
|
log.warning("upload_refs: session limit exceeded, skip sid=%s file=%r", sid, name)
|
||||||
|
try:
|
||||||
|
client.delete(url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
client.delete(url) # убрать файл с ВМ после загрузки
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
added += 1
|
||||||
|
except Exception as e:
|
||||||
|
log.error("upload_refs: pull error sid=%s: %r", sid, e)
|
||||||
|
return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502
|
||||||
|
|
||||||
|
if sink:
|
||||||
|
return jsonify({"ok": True, "results": results})
|
||||||
|
log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid))
|
||||||
|
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
|
||||||
|
|
||||||
|
return bp
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Параметры слоя 2 (бэк) по умолчанию.
|
||||||
|
|
||||||
|
Переопределяются из конфига приложения через create_upload_refs_blueprint(cfg).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
|
||||||
|
PULL_RETRIES = 3
|
||||||
|
PULL_RETRY_DELAY = 2 # секунды между попытками
|
||||||
|
|
||||||
|
# Доверенный префикс ВМ-буфера — валидация URL при pull (защита от SSRF)
|
||||||
|
VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""pull_file — вытащить файл с ВМ-буфера исходящим GET с ретраями."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .config import PULL_RETRIES, PULL_RETRY_DELAY
|
||||||
|
|
||||||
|
log = logging.getLogger("upload.upload_refs.pull")
|
||||||
|
|
||||||
|
|
||||||
|
def pull_file(client, url: str, retries: int = PULL_RETRIES,
|
||||||
|
delay: float = PULL_RETRY_DELAY, sid: str = None, name: str = None) -> bytes:
|
||||||
|
"""GET url с ретраями; читает по частям (stream).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: httpx.Client
|
||||||
|
url: URL файла на ВМ-буфере.
|
||||||
|
retries: число попыток.
|
||||||
|
delay: пауза между попытками (сек).
|
||||||
|
sid/name: для логирования (опционально).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Содержимое файла (bytes).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Последнюю ошибку попытки, если все ретраи не удались.
|
||||||
|
"""
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
with client.stream("GET", url) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
return b"".join(resp.iter_bytes())
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
log.warning("pull: attempt %d/%d failed sid=%s file=%r: %r",
|
||||||
|
attempt + 1, retries, sid, name, e)
|
||||||
|
time.sleep(delay)
|
||||||
|
raise last_err if last_err else RuntimeError("pull failed")
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""safe_name — санитизация имени файла (защита от path traversal)."""
|
||||||
|
|
||||||
|
|
||||||
|
def safe_name(name: str) -> str:
|
||||||
|
"""Санитизировать имя файла: защита от path traversal, сохраняя подпапки.
|
||||||
|
|
||||||
|
Запрещает '..' и абсолютные пути; нормализует слэши. Возвращает "" если
|
||||||
|
имя пустое или небезопасное.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
name = name.replace("\\", "/")
|
||||||
|
parts = [p for p in name.split("/") if p and p != "."]
|
||||||
|
if not parts or any(p == ".." for p in parts):
|
||||||
|
return ""
|
||||||
|
return "/".join(parts)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"vmUploadUrl": "https://contracts.kube5s.ru/drhider-upload/",
|
||||||
|
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
|
||||||
|
"maxFileBytes": 52428800,
|
||||||
|
"maxSessionBytes": 524288000,
|
||||||
|
"apiPrefix": "/api",
|
||||||
|
"vmUploadPrefix": "https://contracts.kube5s.ru/drhider-upload/",
|
||||||
|
"pullRetries": 3,
|
||||||
|
"pullRetryDelay": 2,
|
||||||
|
"pullTimeout": 120,
|
||||||
|
"ttlSeconds": 1800,
|
||||||
|
"estMbSec": 12
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "upload-frontend",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Переиспользуемый фронт слоёв 1 и 2 (выбор файлов + закачка через ВМ). Модули ES — подключаются в браузере через <script type=\"module\">; Node-режим нужен для юнит-тестов zip."
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// addFileWithDedup — дедуп «имя+размер», суффиксы _2/_3, лимиты (over).
|
||||||
|
// Мутирует state. Возвращает true если файл добавлен (или переведён в over),
|
||||||
|
// false если это дедуп (обновлена только дата).
|
||||||
|
|
||||||
|
export function addFileWithDedup(state, file, cfg) {
|
||||||
|
const size = file.size;
|
||||||
|
const mtime = file.lastModified;
|
||||||
|
let name = file.name;
|
||||||
|
const maxFileBytes = cfg.maxFileBytes;
|
||||||
|
const maxSessionBytes = cfg.maxSessionBytes;
|
||||||
|
|
||||||
|
if (state.fileMeta.has(name)) {
|
||||||
|
const e = state.fileMeta.get(name);
|
||||||
|
if (e.size === size) {
|
||||||
|
// Тот же файл (имя+размер) — дедуп. Дату не сравниваем: файл могли пересохранить
|
||||||
|
// с тем же содержимым. Оставляем более свежий по дате.
|
||||||
|
if (mtime > e.mtime) {
|
||||||
|
e.mtime = mtime;
|
||||||
|
const idx = state.files.findIndex(f => f.name === name);
|
||||||
|
if (idx >= 0) state.files[idx] = new File([file], name, { lastModified: mtime });
|
||||||
|
}
|
||||||
|
return false; // дедуп: файл не добавлен (обновлена только дата)
|
||||||
|
}
|
||||||
|
// Имя то же, размер другой — добавить с суффиксом _2, _3...
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
const base = dot > 0 ? name.slice(0, dot) : name;
|
||||||
|
const ext = dot > 0 ? name.slice(dot) : '';
|
||||||
|
let n = 2;
|
||||||
|
while (state.fileMeta.has(base + '_' + n + ext)) n++;
|
||||||
|
name = base + '_' + n + ext;
|
||||||
|
}
|
||||||
|
state.fileMeta.set(name, { size, mtime });
|
||||||
|
|
||||||
|
// Определяем, превышает ли файл лимит (по размеру файла или суммарный) — не участвует в обфускации
|
||||||
|
let over = false;
|
||||||
|
if (size > maxFileBytes) over = true; // лимит на один файл
|
||||||
|
const sum = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : f.size), 0);
|
||||||
|
if (sum + size > maxSessionBytes) over = true; // суммарный лимит сессии
|
||||||
|
|
||||||
|
if (over) {
|
||||||
|
state.overNames.add(name);
|
||||||
|
state.files.push(new File([file], name, { lastModified: mtime }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
state.files.push(new File([file], name, { lastModified: mtime }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user