Author SHA1 Message Date
“Naeel” c2344f9738 bump 2.0.12 (этап 2: модуль upload через sink)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 08:15:48 +03:00
“Naeel” db58a433fb этап 2: переиспользуемый модуль upload (sink) вместо рукописного транспорта
- копирую модуль upload/ из drhider (слои 1-2)
- blueprint: параметр sink (drhider-сессия по умолчанию, сверка — DB+парсинг)
- upload_bp: contracts_upload_sink = _store_and_parse
- routes: регистрирую create_upload_refs_blueprint(cfg, sink=...)
- app.py: корень репо в sys.path (для import upload)
- History: план переиспользования + ревью Соннета
2026-08-26 08:07:43 +03:00
“Naeel” 55bd7a027d v2.0.11: вся загрузка через ВМ-буфер (ZIP + .doc)
Deploy contracts-flask / validate (push) Canceled after 0s
- /api/unzip_refs: pull ZIP с ВМ + распаковка
- /api/convert_refs: pull .doc с ВМ + конвертация в .docx
- convertDoc/addZipFile: PUT на ВМ вместо прямого multipart
2026-08-24 22:08:01 +03:00
“Naeel” 37ea5e2c31 v2.0.10: nginx /contracts-upload/ на alias + CORS как у drhider
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-24 21:51:48 +03:00
“Naeel” 78045f2c81 v2.0.10: загрузка через ВМ-буфер (паттерн drhider)
Deploy contracts-flask / validate (push) Canceled after 0s
- /api/upload_refs: pull файлов с ВМ (SSRF-guard, лимит 50МБ, ретраи, DELETE)
- uploadFile: PUT на WebDAV /contracts-upload/ → refs → pull
- nginx: location /contracts-upload/ (WebDAV + CORS для managed-фронта)
2026-08-24 21:45:46 +03:00
naeel 4653aa5e8e Docs: диагностика HTTP 502 на 19МБ PDF — OOMKill пода (лимит 1Gi)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-17 18:32:06 +04:00
“Naeel” 956aed0971 v2.0.9: правильные лого и фавикон из design/ 2026-07-16 10:58:09 +04:00
“Naeel” 3b2e576284 v2.0.9: фавикон nubes.ru (U-арка) 2026-07-16 10:56:58 +04:00
“Naeel” de05d746cc v2.0.8: конвертация .doc → .docx через convert-service перед upload 2026-07-16 10:42:21 +04:00
“Naeel” c677206d03 v2.0.7: .doc в accept и подсказке 2026-07-16 10:29:14 +04:00
“Naeel” d1415d5d21 v2.0.6: convert-service интеграция 2026-07-16 10:20:44 +04:00
“Naeel” 5d8bcd61ea v2.0.5: интеграция с convert-service (HTTP вместо subprocess libreoffice) 2026-07-16 10:18:17 +04:00
“Naeel” 6334ca2e3e v2.0.5: заголовок страницы 2026-07-16 09:07:59 +04:00
“Naeel” 58bc80371d v2.0.5: вернуть версию 2.0.5 2026-07-16 09:05:50 +04:00
“Naeel” a15c3e8e94 v2.0.4: вернуть версию (честный счётчик загрузки) 2026-07-16 09:04:47 +04:00
“Naeel” f4bae5a6b9 v2.0.5: честный счётчик ⏳ соединение... Nс вместо фейкового ↑N% при upload 2026-07-16 09:02:56 +04:00
“Naeel” 4f9bee4c1e v2.0.4 2026-07-16 08:30:35 +04:00
“Naeel” 2d5606d6d7 fix: check_arithmetic(ops) — was passing contract_id (string) instead of ops list 2026-07-16 08:29:33 +04:00
“Naeel” cc3a53a229 fix: spec_events.py — from compare.metrics → from services.metrics 2026-07-16 08:28:47 +04:00
“Naeel” 1bd62a51ef v2.0.3 — bump version 2026-07-16 08:23:18 +04:00
“Naeel” 2fd5f2944f fix: spec_events schema — add seq,action,target_hash,new_values,comment,status columns 2026-07-16 08:22:04 +04:00
“Naeel” b5d97bdd98 docs: ⛔ НЕ МЕНЯТЬ комментарии в files.js — загрузка проверена в бою 2026-07-16 08:20:16 +04:00
“Naeel” ae8f6819fc fix: bust JS cache — version query string 1.0.179→2.0.2 2026-07-16 08:16:23 +04:00
“Naeel” 04cb3f5bfe fix: process.py — use apply_ops() instead of phantom add_row/update_row/delete_row 2026-07-16 08:09:29 +04:00
“Naeel” e4c010acd3 fix: all rows visible immediately, then upload one by one 2026-07-16 07:51:42 +04:00
“Naeel” b8340a0637 v2.0.2: fix SQLite — UUID in all INSERTs, now()→datetime, ::jsonb cleanup 2026-07-16 07:29:51 +04:00
“Naeel” 9e22182f9a v2.0.1: XHR→fetch, no .doc, progress restore, bump version 2026-07-16 07:20:28 +04:00
“Naeel” abedffe4d0 fix: restore upload progress display — fake % via setInterval, fetch() for no RST 2026-07-16 07:16:31 +04:00
“Naeel” 11015fd55b fix: remove .doc support — libreoffice unavailable on Штурвал 2026-07-16 07:01:44 +04:00
“Naeel” ea92730bd8 fix: XHR → fetch() for upload — drhider v0.0.29 fix for ERR_CONNECTION_RESET 2026-07-16 07:00:52 +04:00
“Naeel” d8d53661d9 fix: remove http2=True — h2 package not installed on Штурвал 2026-07-15 12:13:08 +04:00
“Naeel” 49dd873db3 fix: warmup fetch('/health') on page load — prevents first-upload RST 2026-07-15 11:57:03 +04:00
“Naeel” 32660d2590 fix: add app.run() — script was creating app then exiting 2026-07-15 11:46:23 +04:00
“Naeel” f176ddad71 fix: spec_events.py — get_pool → get_conn, remove FOR UPDATE 2026-07-15 11:43:01 +04:00
“Naeel” aafb038921 fix: db/*.py imports — from db.connection, not just connection 2026-07-15 11:40:59 +04:00
“Naeel” f9669755cd fix: direct imports (no site. prefix, no relative) — works with python site/app.py 2026-07-15 11:37:57 +04:00
“Naeel” 15137f8e94 fix: all imports → relative (from .xxx) — no stdlib site conflict 2026-07-15 11:32:49 +04:00
“Naeel” 85a958e2aa fix: sys.path insert + del sys.modules — stronger site conflict workaround 2026-07-15 11:29:57 +04:00
“Naeel” 0e8a8eef66 fix: del sys.modules['site'] — stdlib conflict workaround 2026-07-15 11:27:07 +04:00
“Naeel” cd77e7d709 fix: site/ with __init__.py — proper Python package, no stdlib conflict 2026-07-15 11:23:13 +04:00
“Naeel” 81aba97304 fix: site/ → src/ (stdlib conflict — site is built-in Python module) 2026-07-15 11:16:42 +04:00
“Naeel” a7b53fde19 feat: PostgreSQL → SQLite — thread-local, WAL, session-key, os.remove cleanup 2026-07-15 10:56:42 +04:00
“Naeel” 96718fc065 revert: app/ → site/ (drhider proves site/ works fine) 2026-07-14 22:56:19 +04:00
“Naeel” 38547404f5 fix: add /chat and /chat.cfm endpoint (was missing) 2026-07-14 22:33:13 +04:00
“Naeel” 8f688215cc Steps 3-8: config, blueprints (upload/pipeline/api/prompts/health/pages), new app.py (no VM proxy), process.py adapted to generator, VM_API='' 2026-07-14 22:30:58 +04:00
“Naeel” e635cb855a Step 2: copy compare/ services to app/services/ with fixed imports 2026-07-14 22:27:30 +04:00
“Naeel” fc08db0c76 Step 1: copy db/ modules + llm_prompt.py to app/ 2026-07-14 22:25:38 +04:00
“Naeel” 976e59b496 Step 0: rename site/ → app/ (Python stdlib conflict fix) 2026-07-14 22:24:24 +04:00
“Naeel” ca7b70fe41 fix: drhider modal header sticky (v1.15) 2026-07-07 17:01:40 +04:00
“Naeel” 16ef92fc74 fix: drhider help — pdfplumber not LibreOffice (v1.14) 2026-07-07 16:59:49 +04:00
“Naeel” 819193c6ad fix: drhider PDF indicator short (*.docx) v1.13 2026-07-07 16:43:47 +04:00
“Naeel” f68597f680 fix: drhider PDF→DOCX via pdfplumber (LibreOffice cant do it) v1.12 2026-07-07 16:37:36 +04:00
“Naeel” 88b63733b1 fix: drhider sticky header (v1.11) 2026-07-07 16:27:48 +04:00
“Naeel” 42855fe9b3 feat: drhider PDF→DOCX indicator in file list (v1.10) 2026-07-07 16:23:57 +04:00
“Naeel” 211f391c6c feat: drhider PDF→DOCX conversion (LibreOffice), collision dialog (v1.9) 2026-07-07 16:14:00 +04:00
79 changed files with 5311 additions and 258 deletions
@@ -0,0 +1,100 @@
# Переиспользование модуля загрузки 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
- [ ] Этап 2 — sink (доработка blueprint + contracts-sink + регистрация)
- [ ] Удалить рукописный транспорт (после этапа 2)
- [ ] Этап 3 — UI
@@ -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 МБ при стандартных лимитах.
Результат сравнить с данным диагностикой.
+45 -4
View File
@@ -207,6 +207,8 @@ class TwoPassObfuscator:
"""
# --- Распаковать ZIP-файлы ---
files = self._expand_zips(files)
# --- Конвертировать PDF → DOCX ---
files = self._convert_pdfs_to_docx(files)
try:
# --- Проход 1: сбор сущностей ---
@@ -236,10 +238,6 @@ class TwoPassObfuscator:
pass
elif fname in all_docx:
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:
txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname)
@@ -253,6 +251,49 @@ class TwoPassObfuscator:
self._regex_replacements.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]]:
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть.
Защита от ZIP-бомб: ratio + накопительный размер (как в compare/unzip.py)."""
+16
View File
@@ -92,6 +92,22 @@ server {
client_max_body_size 100m;
}
# ── VM-буфер загрузки contracts (паттерн drhider) ──────────────
# Браузер кладёт файл сюда PUT (мимо шлюза кластера ~64КБ), бэк тянет сам.
# Файлы: /var/www/contracts-upload/ (нужно создать, chown www-data).
# CORS: origin фронтенда = contractor.pythonk8s.dev.nubes.ru (ingress, подтверждено).
location /contracts-upload/ {
alias /var/www/contracts-upload/;
dav_methods PUT DELETE;
create_full_put_path on;
client_max_body_size 1024m;
add_header Access-Control-Allow-Origin https://contractor.pythonk8s.dev.nubes.ru always;
add_header Access-Control-Allow-Methods 'PUT, GET, OPTIONS, DELETE' always;
add_header Access-Control-Allow-Headers 'Content-Type' always;
add_header Access-Control-Max-Age 86400 always;
if ($request_method = OPTIONS) { return 204; }
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot
View File
+37 -116
View File
@@ -1,128 +1,49 @@
"""contracts-flask — Flask-фронтенд для сверки договоров.
1:1 функционал с Lucee-версией.
Все тяжёлые операции — на ВМ (contracts.kube5s.ru).
"""contracts-flask v2.0 — полный перенос с ВМ на Flask.
Больше никаких прокси на contracts.kube5s.ru — всё локально.
"""
import os
import httpx
from flask import Flask, render_template, request, jsonify
import sys, os
# site/ в sys.path — импортируем модули напрямую, без префиксов
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# корень репо — для переиспользуемого модуля upload/ (как в drhider)
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
app = Flask(__name__)
VM_API = os.environ.get("VM_API", "https://contracts.kube5s.ru")
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")
from flask import Flask
from config import VERSION, MAX_CONTENT_LENGTH
from routes import register_routes
@app.route("/")
def index():
return render_template("index.html", vm_api=VM_API)
def create_app():
app = Flask(__name__)
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 chat():
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"})
def _init_db():
"""Создать БД + схему + seed prompts. SQLite — всё в одном файле /tmp."""
from db.connection import init_db
init_db()
try:
with httpx.Client(timeout=10) as client:
resp = client.get(f"{VM_API}/api/spec-current", params={"contract_id": contract_id})
resp.raise_for_status()
rows = resp.json().get("rows", [])
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}"})
from db import prompts as db_prompts
db_prompts.seed_defaults()
except Exception:
pass
@app.route("/api/prompts", methods=["GET"])
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"}
app = create_app()
if __name__ == "__main__":
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()
+24
View File
@@ -0,0 +1,24 @@
"""Конфигурация приложения — все настройки в одном месте."""
import os
VERSION = "2.0.12"
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
View File
+228
View File
@@ -0,0 +1,228 @@
"""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
DB_PATH = "/tmp/contracts.db"
_db_session_key = None
_local = threading.local()
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 [])
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", "")
# gen_random_uuid() → хекс-UUID через randomblob
if "gen_random_uuid()" in sql:
import uuid
sql = sql.replace("gen_random_uuid()", "?")
# BOOLEAN → INTEGER
sql = sql.replace(" BOOLEAN ", " INTEGER ")
sql = sql.replace(" bool ", " INTEGER ")
# 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
+28
View File
@@ -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)"
)
+118
View File
@@ -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}
+154
View File
@@ -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, created_by, 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 сам предложил"),
)
+19
View File
@@ -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
+190
View File
@@ -0,0 +1,190 @@
"""spec_events — event sourcing: apply ops, reset contract."""
import json, uuid
from db.connection import query, execute, get_conn
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 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."""
added = 0
updated = 0
deleted = 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
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
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
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
execute(
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
VALUES (%s, %s, %s, %s, 'UNRESOLVED', %s, %s, %s, 'unresolved', %s, %s, %s)""",
(
str(uuid.uuid4()), contract_id, supplement_id, seq,
op.get("target_hash", ""),
json.dumps(op.get("new_values", {}), ensure_ascii=False),
op.get("reason", op.get("comment", "")),
prompt_id, document_id,
json.dumps(raw_llm_response, ensure_ascii=False),
),
)
seq += 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}")
return {"added": added, "updated": updated, "deleted": deleted}
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:
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,
)
+62
View File
@@ -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,))
+281
View File
@@ -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. Пробует получить активный промпт из БД (Lucee API).
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, ""
+268
View File
@@ -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)
+28
View File
@@ -0,0 +1,28 @@
"""Регистрация всех blueprint'ов."""
def register_routes(app):
import config
from routes.upload_bp import upload_bp, contracts_upload_sink
from routes.pipeline_bp import pipeline_bp
from routes.api_bp import api_bp
from routes.prompts_bp import prompts_bp
from routes.health_bp import health_bp
from routes.pages_bp import pages_bp
from upload.backend.upload_refs import create_upload_refs_blueprint
app.register_blueprint(upload_bp)
app.register_blueprint(pipeline_bp)
app.register_blueprint(api_bp)
app.register_blueprint(prompts_bp)
app.register_blueprint(health_bp)
app.register_blueprint(pages_bp)
# Переиспользуемый слой закачки через ВМ (модуль upload, паттерн drhider)
app.register_blueprint(create_upload_refs_blueprint({
"apiPrefix": "/api",
"vmUploadPrefix": config.VM_UPLOAD_PREFIX,
"maxFileBytes": config.VM_UPLOAD_MAX_BYTES,
"pullRetries": config.PULL_RETRIES,
"pullRetryDelay": config.PULL_RETRY_DELAY,
}, sink=contracts_upload_sink))
+182
View File
@@ -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")
+10
View File
@@ -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})
+14
View File
@@ -0,0 +1,14 @@
"""HTML-страницы."""
from flask import Blueprint, render_template
pages_bp = Blueprint("pages", __name__)
@pages_bp.route("/")
def index():
return render_template("index.html")
@pages_bp.route("/architect")
def architect():
return render_template("architect.html")
+91
View File
@@ -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
+71
View File
@@ -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.insert(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_id)
return jsonify(ok=True)
except Exception as e:
return jsonify(ok=False, error=str(e)), 500
+256
View File
@@ -0,0 +1,256 @@
"""Upload blueprint — загрузка, конвертация, распаковка."""
import io, os, time, base64, hashlib, zipfile
import httpx
from flask import Blueprint, request, jsonify, send_file
from services.parse import parse_file
from db import documents
import config
upload_bp = Blueprint("upload", __name__)
ALLOWED = {"pdf", "docx", "doc", "zip"}
def _check_ext(filename: str) -> str | None:
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext not in ALLOWED:
return f"unsupported format: .{ext} (allowed: {', '.join(sorted(ALLOWED))})"
return None
def _safe_name(name: str) -> str:
"""Санитизация имени файла: только basename, защита от path traversal."""
name = (name or "").replace("\\", "/").rsplit("/", 1)[-1].strip()
if not name or name in (".", ".."):
return "file.bin"
return name[:255]
def _pull_with_retries(url: str, timeout: int = 120) -> bytes:
"""Скачать файл с ВМ-буфера с ретраями (egress, без лимита шлюза)."""
last: Exception | None = None
for attempt in range(1, config.PULL_RETRIES + 1):
try:
resp = httpx.get(url, timeout=timeout, follow_redirects=False)
if resp.status_code == 200:
return resp.content
last = Exception(f"HTTP {resp.status_code}")
except Exception as e:
last = e
if attempt < config.PULL_RETRIES:
time.sleep(config.PULL_RETRY_DELAY)
raise last or Exception("pull failed")
def _delete_from_vm(url: str) -> None:
"""Best-effort удаление файла с ВМ-буфера."""
try:
httpx.delete(url, timeout=30)
except Exception:
pass
def _pull_from_ref(ref):
"""SSRF-проверка → лимит → pull с ретраями → DELETE с ВМ. Возвращает (name, content)."""
name = _safe_name(str(ref.get("name", "") or ""))
size = int(ref.get("size") or 0)
url = str(ref.get("url", "") or "")
if not url.startswith(config.VM_UPLOAD_PREFIX):
raise Exception("invalid url (SSRF guard)")
if size > config.VM_UPLOAD_MAX_BYTES:
_delete_from_vm(url)
raise Exception(f"file too large: {size} bytes (max {config.VM_UPLOAD_MAX_BYTES})")
content = _pull_with_retries(url)
if len(content) > config.VM_UPLOAD_MAX_BYTES:
_delete_from_vm(url)
raise Exception("file too large after pull")
_delete_from_vm(url)
return name, content
def _unzip(data: bytes):
"""Распаковать ZIP → (ok, files, error)."""
MAX_FILES = 500
MAX_UNCOMPRESSED = 500 * 1024 * 1024 # 500 MB
files = []
total = 0
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
if len(zf.namelist()) > MAX_FILES:
return False, None, f"too many files in ZIP (max {MAX_FILES})"
for info in zf.infolist():
if info.is_dir():
continue
name = os.path.basename(info.filename)
if not name or ".." in name or "/" in name or "\\" in name:
continue
raw = zf.read(info)
total += len(raw)
if total > MAX_UNCOMPRESSED:
return False, None, "total uncompressed size exceeds 500 MB"
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
files.append({
"filename": name,
"ext": ext,
"size": len(raw),
"data_b64": base64.b64encode(raw).decode(),
})
except zipfile.BadZipFile:
return False, None, "invalid ZIP archive"
return True, files, None
def _convert(filename: str, data: bytes) -> bytes:
""".doc → .docx через внешний libreoffice-сервис. Возвращает docx-байты."""
try:
resp = httpx.post(
config.CONVERT_SERVICE_URL + "/convert",
files={"file": (filename, data, "application/msword")},
timeout=120,
)
except httpx.TimeoutException:
raise Exception("conversion timeout")
if resp.status_code != 200:
try:
err = resp.json().get("error", "conversion failed")
except Exception:
err = "conversion failed"
raise Exception(err)
return resp.content
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}
@upload_bp.route("/upload", methods=["POST"])
def upload():
"""Загрузка одного файла + авто-парсинг → БД (прямой multipart)."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
err = _check_ext(f.filename)
if err:
return jsonify(ok=False, error=err), 400
data = f.read()
batch_id = request.form.get("batch_id")
zip_source = request.form.get("zip_source")
contract_id = request.form.get("contract_id")
result = _store_and_parse(f.filename, data, batch_id, contract_id, zip_source, f.content_type or "application/octet-stream")
if not result["ok"]:
return jsonify(ok=result["ok"], error=result.get("error"), doc_id=result.get("doc_id")), 200
return jsonify(ok=True, doc_id=result["doc_id"], contract_id=result["contract_id"], parsed=result["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=...) на каждый
вытянутый с ВМ файл. Возвращает dict с ключами ok/doc_id/contract_id/parsed.
"""
return _store_and_parse(name, content, batch_id, contract_id, zip_source)
@upload_bp.route("/convert-doc", methods=["POST"])
def convert_doc():
""".doc → .docx через внешний libreoffice-сервис (прямой multipart)."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
try:
content = _convert(f.filename, f.read())
except Exception as e:
return jsonify(ok=False, error=str(e)), 500
return send_file(
io.BytesIO(content),
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
@upload_bp.route("/api/convert_refs", methods=["POST"])
def convert_refs():
""".doc → .docx через ВМ-буфер (паттерн drhider): pull .doc с ВМ → конвертация → docx."""
data = request.get_json(silent=True) or {}
files = data.get("files") or []
if not files:
return jsonify(ok=False, error="no files"), 400
ref = files[0]
try:
name, content = _pull_from_ref(ref)
except Exception as e:
return jsonify(ok=False, error=str(e)), 400
try:
docx = _convert(name, content)
except Exception as e:
return jsonify(ok=False, error=str(e)), 500
return send_file(
io.BytesIO(docx),
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
@upload_bp.route("/unzip-upload", methods=["POST"])
def unzip_upload():
"""Распаковать ZIP → список файлов (base64 для фронтенда, прямой multipart)."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
ok, files, err = _unzip(f.read())
if not ok:
return jsonify(ok=False, error=err), 400
return jsonify(ok=True, files=files)
@upload_bp.route("/api/unzip_refs", methods=["POST"])
def unzip_refs():
"""Распаковать ZIP через ВМ-буфер (паттерн drhider): pull ZIP с ВМ → распаковка."""
data = request.get_json(silent=True) or {}
files = data.get("files") or []
if not files:
return jsonify(ok=False, error="no files"), 400
ref = files[0]
try:
name, content = _pull_from_ref(ref)
except Exception as e:
return jsonify(ok=False, error=str(e)), 400
ok, unzipped, err = _unzip(content)
if not ok:
return jsonify(ok=False, error=err), 400
return jsonify(ok=True, files=unzipped)
+267
View File
@@ -0,0 +1,267 @@
"""
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, os
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
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
LLM_MODEL = "gpt-oss-120b"
# Ленивый 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 = [
'СЧЕТ-ФАКТУРА', 'СЧЕТ НА ОПЛАТУ', 'АКТ СВЕРКИ',
'АКТ ОКАЗАННЫХ УСЛУГ', 'АКТ ВЫПОЛНЕННЫХ РАБОТ',
'ПЛАТЁЖНОЕ ПОРУЧЕНИЕ', 'УНИВЕРСАЛЬНЫЙ ПЕРЕДАТОЧНЫЙ',
'УПД', 'ПЛАТЕЖНОЕ ПОРУЧЕНИЕ',
]
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)
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()
# Сбросить статус — allow re-classify after file changes
_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]
+45 -4
View File
@@ -207,6 +207,8 @@ class TwoPassObfuscator:
"""
# --- Распаковать ZIP-файлы ---
files = self._expand_zips(files)
# --- Конвертировать PDF → DOCX ---
files = self._convert_pdfs_to_docx(files)
try:
# --- Проход 1: сбор сущностей ---
@@ -236,10 +238,6 @@ class TwoPassObfuscator:
pass
elif fname in all_docx:
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:
txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname)
@@ -253,6 +251,49 @@ class TwoPassObfuscator:
self._regex_replacements.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]]:
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть."""
result = []
+160
View File
@@ -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}
+35
View File
@@ -0,0 +1,35 @@
"""LLM service — call LLM API with optional DI."""
import os, json
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
LLM_MODEL = "gpt-oss-120b"
# Ленивый 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
+1 -1
View File
@@ -36,7 +36,7 @@ class HttpxLLMClient:
"max_tokens": self.max_tokens,
"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(
self.url,
json=payload,
+88
View File
@@ -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
+87
View File
@@ -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]}
+149
View File
@@ -0,0 +1,149 @@
"""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")
yield {
"type": "llm_done",
"supplement_id": sid,
"filename": filename,
"ops_count": len(ops),
"mode": mode,
"time_s": round(time.time() - t1, 1),
}
# 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
check_arithmetic(ops)
yield {
"type": "applied",
"supplement_id": sid,
"filename": filename,
"summary": summary,
"ops": applied_ops,
}
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": "complete", "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)
+14 -12
View File
@@ -1,10 +1,12 @@
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
// Contracts App — весь JS на VM (contracts.kube5s.ru)
// Lucee: только домен + index.cfm-скелет
var VM_API = 'https://check.kube5s.ru';
var UPLOAD_URL = VM_API + '/upload';
var CONVERT_URL = VM_API + '/convert-doc';
var UNZIP_URL = VM_API + '/unzip-upload';
// Contracts App v2.0 — всё на Flask, ВМ больше нет
var VM_API = '';
var UPLOAD_URL = '/upload';
var CONVERT_URL = '/convert-doc';
var UNZIP_URL = '/unzip-upload';
// ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза),
// бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ.
var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/';
// SITE_URL удалён (Фаза 4) — не использовался
var fileInput = document.getElementById('fileInput');
@@ -14,11 +16,11 @@ var fileTable = document.getElementById('fileTable');
// state.batchId — теперь в state.js (Фаза 0: state + render)
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
// Автоочистка старых записей при загрузке страницы
(async function cleanup() {
try {
await fetch(VM_API + '/api/cleanup', { method: 'POST' });
} catch(e) { /* ignore */ }
// Прогрев upstream + автоочистка при загрузке страницы
// ⚠️ Без прогрева первый POST может упасть с ERR_CONNECTION_RESET (MTU/Geneve)
(async function init() {
try { await fetch('/health'); } catch(e) { /* ignore */ }
try { await fetch(VM_API + '/api/cleanup', { method: 'POST' }); } catch(e) { /* ignore */ }
})();
/**
@@ -328,7 +330,7 @@ document.getElementById('llmBtn').addEventListener('click', function() {
compareStatus.textContent = '⏳ 0.0с';
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) : '');
// Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
+55 -4
View File
@@ -1,4 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#001C34"/>
<text x="16" y="24" text-anchor="middle" font-size="22" font-weight="bold" fill="white" font-family="sans-serif">N</text>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="57"
height="57"
xml:space="preserve"
overflow="hidden"
version="1.1"
id="svg8"
sodipodi:docname="U_v3.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview8"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="16.226667"
inkscape:cx="27.146672"
inkscape:cy="28.317584"
inkscape:window-width="2560"
inkscape:window-height="1494"
inkscape:window-x="-11"
inkscape:window-y="-11"
inkscape:window-maximized="1"
inkscape:current-layer="svg8" /><defs
id="defs2"><clipPath
id="clip0"><rect
x="652"
y="420"
width="64"
height="75"
id="rect1" /></clipPath><clipPath
id="clip1"><rect
x="652"
y="420"
width="64"
height="70"
id="rect2" /></clipPath></defs><g
clip-path="url(#clip0)"
transform="matrix(1.0135748,0,0,1.0135748,-664.97034,-440.30567)"
id="g8"><g
clip-path="url(#clip1)"
id="g7"><g
id="g6"><path
d="m 114.028,43.1492 v 7.6592 c 0,3.7843 -3.08,6.8632 -6.866,6.8632 L 85.3367,57.5419 c -3.7853,0 -6.8655,-3.0805 -6.8655,-6.8643 v -2.5279 l 0.0555,0.009 V 15.8148 l -10.3823,2.0127 0.0045,3.8772 -0.0045,0.0015 v 28.971 c 0,9.481 7.7132,17.1923 17.1867,17.1923 l 21.8359,0.1313 c 9.474,0 17.187,-7.7117 17.187,-17.1928 v -2.6541 l 0.027,0.0045 V 15.8145 l -10.382,2.0126 0.028,25.3214 z"
fill="#001c34"
fill-rule="evenodd"
transform="matrix(1,0,0,1.01337,587.92,420.059)"
id="path6" /></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 246 B

After

Width:  |  Height:  |  Size: 2.0 KiB

+176 -102
View File
@@ -1,27 +1,24 @@
/**
* 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 для файлов
* ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔
*
* ЗАВИСИМОСТИ (глобальные, загружаются раньше):
* state.js → state (центральное состояние)
* app_utils.js → escHtml, formatSize, formatDate
* app.js → render(), stepDone, stepActive, resetStepper, showClassifyBtn
* v2.0.3: честный счётчик ⏳ соединение... Nс вместо фейкового ↑N%
* Проверено в бою 2026-07-16. Любое изменение = риск сломать загрузку.
*
* ЗАГРУЖАЕТСЯ: после app_utils.js, перед app.js
*/
* КЛЮЧЕВЫЕ ФУНКЦИИ (не трогать):
* - uploadFile() — fetch-загрузка с имитацией прогресса
* - onFilesSelected() — все строки в таблицу сразу, потом загрузка по одной
* - statusToHTML() — рендер статуса (↑ N%, ✓)
* - renderFiles() — рендер всей таблицы
var CONVERT_URL = '/convert-doc';
/**
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
*
* Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | ''
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | '' | 'connecting'
* Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
*
* ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
@@ -30,7 +27,9 @@
function statusToHTML(st) {
if (!st || !st.kind) return '';
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 'unzipping': return '⏳ распаковка...';
case 'parsing': return '⏳ парсинг...';
@@ -55,7 +54,7 @@ function statusToHTML(st) {
*/
function renderFiles(state) {
if (state.files.length === 0) {
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .doc / .docx / .pdf</td></tr>';
lucide.createIcons();
return;
}
@@ -208,64 +207,105 @@ window.toggleClassifyDetail = async function(i) {
};
/**
* uploadFile(file, onProgress) — XHR-загрузка одного файла на бэкенд.
* convertDoc(file, onProgress) — .doc → .docx через ВМ-буфер (паттерн drhider).
* Фаза 1: PUT .doc на ВМ. Фаза 2: /api/convert_refs → pull → конвертация → .docx.
*/
function convertDoc(file, onProgress) {
var startTime = Date.now();
if (onProgress) onProgress({ kind: 'converting', elapsed: 0 });
var token = crypto.randomUUID();
var vmUrl = VM_UPLOAD_URL + token + '_0';
var timer = setInterval(function() {
var elapsed = Math.floor((Date.now() - startTime) / 1000);
if (onProgress) onProgress({ kind: 'converting', elapsed: elapsed });
}, 1000);
return fetch(vmUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' } })
.then(function(r) {
if (!r.ok) throw new Error('Конвертация: VM upload HTTP ' + r.status);
return fetch('/api/convert_refs?_=' + Date.now(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files: [{ name: file.name, size: file.size, url: vmUrl }] })
});
})
.then(function(r) {
if (!r.ok) throw new Error('Конвертация: HTTP ' + r.status);
return r.blob();
})
.then(function(blob) {
clearInterval(timer);
if (blob.size === 0) throw new Error('Конвертация: пустой результат');
return new File([blob], file.name.replace(/\.doc$/i, '.docx'), {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
lastModified: Date.now()
});
})
.catch(function(e) {
clearInterval(timer);
if (e.message === 'Failed to fetch' || e.name === 'TypeError') throw new Error('Конвертация: Сеть');
throw e;
});
}
/**
* ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ uploadFile — загрузка через ВМ-буфер (паттерн drhider).
*
* Особенности:
* - .doc (не .docx!) конвертируется через CONVERT_URL перед загрузкой
* - onProgress(pct) — callback с процентом загрузки (0-100)
* - Возвращает Promise<ответ API> с полями doc_id, contract_id, parsed
* - Таймаут 180с (большие PDF)
* Фаза 1: PUT файла на ВМ (WebDAV /contracts-upload/, мимо шлюза кластера ~64КБ).
* Фаза 2: POST /api/upload_refs {files:[{name,size,url}]} — бэк сам тянет файл с ВМ.
* Честный счётчик времени: ⏳ соединение... Nс → ⏳ отправка... Nс (fetch не даёт progress).
*/
function uploadFile(file, onProgress, zipSource) {
return new Promise(function(resolve, reject) {
// .doc → конвертация в docx (старый формат Word)
var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx');
var uploadFile = file;
var uploadName = file.name;
var startTime = Date.now();
var phase = 'connecting'; // connecting → uploading
if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 });
var token = crypto.randomUUID();
var vmUrl = VM_UPLOAD_URL + token + '_0';
function doUpload() {
var xhr = new XMLHttpRequest();
var fd = new FormData();
fd.append('files', uploadFile, uploadName);
if (state.contractId) fd.append('contract_id', state.contractId);
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);
}
// Честный счётчик: каждую секунду обновляем elapsed
var timer = setInterval(function() {
var elapsed = Math.floor((Date.now() - startTime) / 1000);
if (onProgress) onProgress({ kind: phase, elapsed: elapsed });
}, 1000);
if (isDoc) {
var xhr = new XMLHttpRequest();
xhr.open('POST', CONVERT_URL);
xhr.responseType = 'blob';
xhr.onload = function() {
if (xhr.status === 200 && xhr.response.size > 100) {
uploadFile = xhr.response;
uploadName = file.name.replace(/\.doc$/i, '.docx');
doUpload();
} else {
reject(new Error('Конвертация .doc'));
}
};
xhr.onerror = function() { reject(new Error('Конвертер')); };
xhr.send(file);
} else {
doUpload();
}
});
// Фаза 1: PUT файла на ВМ-буфер
return fetch(vmUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' } })
.then(function(r) {
if (!r.ok) throw new Error('VM upload HTTP ' + r.status);
phase = 'uploading';
// Фаза 2: refs на бэкенд → pull с ВМ
var body = { batch_id: state.batchId, files: [{ name: file.name, size: file.size, url: vmUrl }] };
if (state.contractId) body.contract_id = state.contractId;
if (zipSource) body.zip_source = zipSource;
return fetch('/api/upload_refs?_=' + Date.now(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
})
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function(data) {
clearInterval(timer);
if (data.ok && 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;
});
}
/**
@@ -355,19 +395,18 @@ async function addZipFile(file) {
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);
// Шаг 1: распаковать ZIP через ВМ-буфер (паттерн drhider)
var token = crypto.randomUUID();
var vmUrl = VM_UPLOAD_URL + token + '_0';
var putResp = await fetch(vmUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' } });
if (!putResp.ok) throw new Error('unzip failed: VM upload HTTP ' + putResp.status);
var refsResp = await fetch('/api/unzip_refs?_=' + Date.now(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files: [{ name: file.name, size: file.size, url: vmUrl }] })
});
if (!refsResp.ok) throw new Error('unzip failed: HTTP ' + refsResp.status);
var zipResp = await refsResp.json();
if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed');
// Подтверждение: показать первые 10 файлов + итог
@@ -425,7 +464,7 @@ async function addZipFile(file) {
*/
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 };
var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'connecting', elapsed: 0 }, zip_source: zipSource || null };
// ДЕДУПЛИКАЦИЯ: ключ = (zip_source, name), чтобы одноимённые файлы из разных ZIP не затирались
var dupKey = (zipSource || '') + '/' + file.name;
@@ -449,9 +488,18 @@ async function addRegularFile(file, zipSource) {
render(state);
try {
// XHR-загрузка с прогрессом
var resp = await uploadFile(file, function(pct) {
state.files[rowIdx].status = { kind: 'uploading', pct: pct };
// .doc → конвертация через libreoffice-сервис
var uploadTarget = file;
if (file.name.toLowerCase().endsWith('.doc')) {
uploadTarget = await convertDoc(file, function(st) {
state.files[rowIdx].status = st;
render(state);
});
state.files[rowIdx].name = uploadTarget.name;
}
// fetch-загрузка с честным счётчиком времени
var resp = await uploadFile(uploadTarget, function(st) {
state.files[rowIdx].status = st;
render(state);
}, zipSource);
// Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys)
@@ -511,36 +559,62 @@ async function finalizeUpload() {
}
/**
* onFilesSelected(newFiles) — Оркестратор загрузки (Фаза 1).
* ⛔ НЕ МЕНЯТЬ ⛔ onFilesSelected — все строки сразу, потом загрузка.
*
* ПАТТЕРН:
* 1. Для каждого файла: addZipFile (ZIP) или addRegularFile (обычный)
* 2. finalizeUpload — завершить цикл
*
* НЕ удаляет существующие файлы — только добавляет новые.
* Дубликаты обрабатываются через confirm() в addRegularFile.
* Удаление — только вручную (кнопка ✕).
*
* Вызывается из fileInput.addEventListener('change', ...).
* v2.0.2: Фаза 1 — все строки в таблицу. Фаза 2 — загрузка по одной.
* Юзер видит таблицу целиком, каждая строка обновляется независимо.
*/
async function onFilesSelected(newFiles) {
if (newFiles.length === 0) return;
fileInput.disabled = true;
// Сбросить прогресс пайплайна при добавлении новых файлов
resetStepper('stepUpload');
// Обработать каждый файл
// Фаза 1: ВСЕ строки в таблицу сразу
for (var i = 0; i < newFiles.length; i++) {
var f = newFiles[i];
if (f.name.toLowerCase().endsWith('.zip')) {
await addZipFile(f);
} else {
await addRegularFile(f);
continue;
}
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 {
// .doc → конвертация через libreoffice-сервис
var uploadTarget = f;
if (f.name.toLowerCase().endsWith('.doc')) {
uploadTarget = await convertDoc(f, function(st) {
entry.status = st;
render(state);
});
entry.name = uploadTarget.name;
}
var resp = await uploadFile(uploadTarget, function(st) {
entry.status = st;
render(state);
});
if (resp && resp.contract_id) state.contractId = resp.contract_id;
entry.doc_id = resp.doc_id;
entry.status = { kind: 'uploaded' };
entry.uploaded = true;
var pr = resp.parsed;
var elapsed = pr && pr.status === 'parsed' ? '0.0' : null;
applyParseResult(entry, pr, elapsed);
} catch(err) {
entry.status = { kind: 'error', text: err.message };
}
render(state);
}
// Завершить цикл
await finalizeUpload();
}
+68 -7
View File
@@ -14,7 +14,7 @@
}
* { 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); }
.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 .sep { color: var(--muted); }
.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.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-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-close { cursor: pointer; font-size: 20px; color: var(--muted); background: none; border: none; line-height: 1; }
.modal-close:hover { color: var(--fg); }
@@ -84,7 +84,7 @@
<a href="https://contractor.pythonk8s.services.ngcloud.ru/">Сверка договоров</a>
<span class="sep">|</span>
<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>
</header>
@@ -123,7 +123,7 @@
<h3>✅ Форматы файлов</h3>
<ul>
<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>.zip</strong> — автоматически распаковывается, все файлы внутри обрабатываются.</li>
</ul>
@@ -139,7 +139,7 @@
<strong>.doc не обрабатывается:</strong> старые Word-файлы возвращаются без изменений.
</div>
<div class="warn">
<strong>PDF → текст:</strong> таблицы, графика и форматирование теряются.
<strong>PDF → DOCX:</strong> извлекаются только текст и таблицы. Шрифты, цвета и позиционирование теряются. Сканы (фотографии страниц) не распознаются.</div>
</div>
<div class="warn">
<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 remove(i) { files.splice(i, 1); 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' : '';
BTN.disabled = !files.length;
}
BTN.onclick = () => {
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;
setStatus('progress', '⏳ Отправка...');
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 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>
<!-- Модальное окно: коллизия 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>
</html>
+8 -8
View File
@@ -73,7 +73,7 @@
<body>
<div class="topbar">
<img src="/static/logo.svg" alt="Nubes">
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">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.12</span></span>
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
<span id="stepUpload">○ Загрузка</span><span>→</span>
<span id="stepClassify">○ Классификация</span><span>→</span>
@@ -90,7 +90,7 @@
Загрузка договоров/приложений/спецификаций
</div>
<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%;">
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
@@ -216,11 +216,11 @@
</div>
</div>
<script src="/static/state.js?v=1.0.179-flask"></script>
<script src="/static/app_utils.js?v=1.0.179-flask"></script>
<script src="/static/files.js?v=1.0.179-flask"></script>
<script src="/static/groups.js?v=1.0.179-flask"></script>
<script src="/static/compare.js?v=1.0.179-flask"></script>
<script src="/static/app.js?v=1.0.179-flask"></script>
<script src="/static/state.js?v=2.0.8"></script>
<script src="/static/app_utils.js?v=2.0.8"></script>
<script src="/static/files.js?v=2.0.11"></script>
<script src="/static/groups.js?v=2.0.8"></script>
<script src="/static/compare.js?v=2.0.8"></script>
<script src="/static/app.js?v=2.0.10"></script>
</body>
</html>
+183
View File
@@ -0,0 +1,183 @@
# upload — переиспользуемые слои загрузки через ВМ
Два самодостаточных слоя для выноса в любой другой проект БЕЗ изменения кода
(меняется только конфиг). Поведение 1:1 с drhider v0.0.75.
```
upload/
frontend/
zip/ # распаковка ZIP (чистые функции)
table/ # слой 1: выбор файлов/папки/архива, дедуп, статусы, таблица
upload/ # слой 2 (фронт): PUT на ВМ + POST /api/upload_refs
backend/
upload_refs/ # слой 2 (бэк): Blueprint upload_refs (SSRF, _safe_name, ретраи)
session/ # in-memory сессия с TTL и лимитами
config.example.json
```
## Что это
| Слой | Где | Ответственность |
|---|---|---|
| **1. Выбор файлов** | фронт | таблица, дедуп, раскрытие ZIP, путь, статусы, кнопки |
| **2. Закачка через ВМ** | фронт + бэк | PUT на ВМ-буфер (фронт) → `upload_refs` pull (бэк) → сессия |
| **3. Логика приложения** | — | у каждого приложения своя (обфускация, SSE и т.п.). В модуле её НЕТ |
Паттерн (зачем ВМ): шлюз managed-кластера рвёт тела >64КБ, egress не ограничен.
Поэтому: браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` → egress `GET` → сессия.
---
## Подключение фронта
Подключить ES-модули (`<script type="module">`) и собрать слой 1:
```js
import { initUploadTable } from './upload/frontend/table/init_upload_table.js';
import { uploadViaVM } from './upload/frontend/upload/upload_via_vm.js';
const cfg = {
allowedExt: ['.pdf', '.doc', '.docx', '.txt', '.md'],
maxFileBytes: 50 * 1024 * 1024,
maxSessionBytes: 500 * 1024 * 1024,
estMbSec: 12,
};
const table = initUploadTable(cfg, {
fileInput: document.getElementById('fileInput'), // <input type="file" multiple>
folderInput: document.getElementById('folderInput'), // <input webkitdirectory>
tableBody: document.getElementById('fileList'), // <tbody>
countEl: document.getElementById('fileCount'),
uploadBtnEl: document.getElementById('uploadBtn'),
onStatus(cls, text) { /* сообщения (cls: ''|'progress'|'done'|'error') */ },
});
// Слой 2 — закачка учитываемых файлов на ВМ:
// idxInSf[k] — индекс k-го отправляемого файла в строках таблицы (своя логика маппинга)
const res = await uploadViaVM(toSend, cfg.vmUploadUrl, {
session: currentSid,
onStatus(k, html) { table.setStatus(idxInSf[k], html); }, // прогресс → ячейка таблицы
onUploadStatus(text) { /* статусная строка */ },
onXHR(xhr) { activeXHR = xhr; }, // регистрация активного PUT для отмены (abort)
});
// res = {ok:true, session, count} | {ok:false, error}
// После этого у вас в сессии res.session лежат файлы — запускайте СВОЮ обработку.
```
API слоя 1 (`initUploadTable(cfg, els)` → `table`):
- `addFiles(File[])` — дедуп + раскрытие ZIP + фильтр + лимиты;
- `getFiles()` → `[{name, size, file}]` — **только учитываемые** (без сверхлимитных);
- `getOverNames()` → `Set` — имена сверх лимита;
- `setStatus(idx, html)` — статус в ячейке таблицы;
- `render()` — перерисовать таблицу;
- `clear()` — очистить список;
- `setBusy(bool)` — заблокировать список на время загрузки/обработки;
- `state` — доступ к состоянию (для слоя 3: установить `state.proc` для 3-секционной таблицы).
---
## Подключение бэка
```python
from flask import Flask
from upload.backend.upload_refs import create_upload_refs_blueprint
from upload.backend.session import create_session, add_file, get_files
app = Flask(__name__)
app.register_blueprint(create_upload_refs_blueprint({
"apiPrefix": "/api", # префикс эндпоинтов
"vmUploadPrefix": "https://.../drhider-upload/", # доверенный префикс (SSRF)
"maxFileBytes": 50 * 1024 * 1024,
"maxSessionBytes": 500 * 1024 * 1024,
"ttlSeconds": 1800,
"pullRetries": 3,
"pullRetryDelay": 2,
}))
```
Эндпоинт: `POST {apiPrefix}/upload_refs` — принимает JSON
`{"session": "...", "files": [{"name", "size", "url"}]}`, тянет каждый файл с ВМ
(SSRF-валидация по `vmUploadPrefix`, `_safe_name`, ретраи), кладёт в сессию.
Возвращает `{"ok": true, "session", "count"}`.
Сессия: `create_session()` → sid; `add_file(sid, name, content)` (лимит 500МБ);
`get_files(sid)` → `[(name, bytes), ...]` или `None`. TTL 30 мин (таймер в фоне).
---
## Раздача модуля в Flask (обязательно)
Фронт-модули — ES-модули, браузер грузит их по HTTP (не через file://).
Добавьте route, который отдаёт папку `upload/frontend` (как в drhider `site/routes/main_bp.py`):
```python
# в любом blueprint приложения
import os
from flask import send_from_directory
_UPLOAD_FRONTEND = os.path.join(os.path.dirname(os.path.dirname(__file__)),
"upload", "frontend")
@bp.route("/upload/<path:filename>")
def upload_frontend(filename):
return send_from_directory(_UPLOAD_FRONTEND, filename)
```
Тогда импорты в браузере: `import { initUploadTable } from '/upload/table/init_upload_table.js';`.
> Если фронт-модули хостятся отдельно (другой домен/приложение) — путь `/upload/...`
> и CORS настраиваются под ваш случай (правка приложения/nginx, не кода модуля).
---
## Слой 3: как подключить обработку (опционально)
Модуль отдаёт файлы в сессию, а обрабатываете их ВЫ (обфускация, конвертация, ...).
После `uploadViaVM` файлы лежат в `res.session`:
```python
files = get_files(res.session) # [(name, bytes), ...]
# ... ваша обработка ...
store_result(res.session, result_zip) # если нужно отдать результат обратно
```
Для 3-секционной таблицы прогресса (готово/текущий/ожидают) модуль предоставляет
`renderProcTable` — достаточно дать слою 3 доступ к `table.state.proc`:
```js
table.state.proc = { phase: 'processing', procState: {}, procExtractRate: null };
function syncProcCtx() {
table.state.proc.phase = phase;
table.state.proc.procState = procState; // {idx: {st, elapsed, eta, chars, t0}}
table.state.proc.procExtractRate = rate; // сек/МБ (для оценок ожидающих)
}
syncProcCtx();
table.render(); // сам выберет renderProcTable при phase==='processing'
```
Статусы в ячейках: `table.setStatus(idx, html)`.
---
## Конфиг (слой 0)
Всё drhider-специфичное задаётся конфигом, а не кодом слоёв:
| Поле | Назначение |
|---|---|
| `vmUploadUrl` | базовый URL ВМ-буфера для PUT (фронт) |
| `vmUploadPrefix` | тот же префикс для SSRF-валидации (бэк) |
| `allowedExt` | расширения документов из папки/архивов |
| `maxFileBytes` / `maxSessionBytes` | лимиты 50 МБ / 500 МБ |
| `apiPrefix` | префикс Blueprint `/api` |
| `pullRetries` / `pullRetryDelay` | ретраи pull (3 × 2с) |
| `pullTimeout` | таймаут одного GET pull (сек) |
| `ttlSeconds` | TTL сессии (по умолчанию 1800) |
| `estMbSec` | оценка времени обработки, сек/МБ (только UI) |
---
## Что НЕ трогать
- Слой 3 — логика приложения (обработка файлов из сессии, SSE-прогресс) у каждого своя.
- CORS на ВМ-буфере — если домен приложения другой, правится nginx на ВМ, а не код модуля.
+8
View File
@@ -0,0 +1,8 @@
"""Переиспользуемый модуль загрузки через ВМ (2 слоя).
Структура:
- frontend/ — слой 1 (выбор файлов) + слой 2 (закачка через ВМ), JS.
- backend/ — слой 2 (бэк): Blueprint upload_refs + in-memory сессия, Python.
Подключение в новый проект — см. README.md.
"""
+1
View File
@@ -0,0 +1 @@
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
+38
View File
@@ -0,0 +1,38 @@
"""In-memory хранилище сессий с TTL и лимитами.
Каждая операция — в отдельном файле (см. ниже), общее состояние — в state.py.
Импорт как единый пакет:
from upload.backend.session import create_session, add_file, get_files
"""
from .create_session import create_session
from .add_file import add_file
from .get_files import get_files, file_count
from .store_result import store_result, get_result
from .store_csv import store_csv, get_csv
from .ttl import touch, pause_ttl, resume_ttl
from .cancel import request_cancel, get_cancel_event
from .cleanup import cleanup
from .state import TTL_SECONDS, MAX_FILE_BYTES, MAX_SESSION_BYTES, configure
__all__ = [
"create_session",
"add_file",
"get_files",
"file_count",
"store_result",
"get_result",
"store_csv",
"get_csv",
"touch",
"pause_ttl",
"resume_ttl",
"request_cancel",
"get_cancel_event",
"cleanup",
"configure",
"TTL_SECONDS",
"MAX_FILE_BYTES",
"MAX_SESSION_BYTES",
]
+25
View File
@@ -0,0 +1,25 @@
"""add_file — добавить файл в сессию (с проверкой суммарного лимита)."""
from . import state
def add_file(sid: str, filename: str, content: bytes) -> bool:
"""Добавить файл в сессию.
Args:
sid: Идентификатор сессии
filename: Имя файла
content: Бинарное содержимое
Returns:
True если добавлено; False если сессии нет или превышен лимит сессии.
"""
with state._lock:
s = state._sessions.get(sid)
if not s:
return False
total = sum(len(c) for _, c in s["files"])
if total + len(content) > state.MAX_SESSION_BYTES:
return False # превышен суммарный лимит сессии
s["files"].append((filename, content))
return True
+27
View File
@@ -0,0 +1,27 @@
"""request_cancel / get_cancel_event — мягкое прерывание обработки сессии."""
import threading
from typing import Optional
from .state import _sessions, _lock
def request_cancel(sid: str) -> bool:
"""Запросить мягкое прерывание обработки сессии.
Returns:
True если сессия существует и отмена запрошена, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["cancel"].set()
return True
def get_cancel_event(sid: str) -> Optional[threading.Event]:
"""Получить событие отмены сессии (или None, если сессии нет)."""
with _lock:
s = _sessions.get(sid)
return s["cancel"] if s else None
+11
View File
@@ -0,0 +1,11 @@
"""cleanup — удалить сессию."""
from .state import _sessions, _lock
def cleanup(sid: str):
"""Удалить сессию и остановить её TTL-таймер."""
with _lock:
s = _sessions.pop(sid, None)
if s and s.get("timer"):
s["timer"].cancel()
+23
View File
@@ -0,0 +1,23 @@
"""create_session — создать новую сессию."""
import threading
import uuid
from .state import _sessions, _lock, _start_timer
def create_session() -> str:
"""Создать новую сессию.
Returns:
Уникальный идентификатор сессии (UUID).
"""
sid = uuid.uuid4().hex
with _lock:
_sessions[sid] = {
"files": [],
"result": None,
"cancel": threading.Event(),
"timer": _start_timer(sid),
}
return sid
+23
View File
@@ -0,0 +1,23 @@
"""get_files / file_count — чтение файлов сессии."""
from typing import List, Optional, Tuple
from .state import _sessions, _lock
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
"""Получить все файлы сессии.
Returns:
[(filename, content), ...] или None если сессия не найдена.
"""
with _lock:
s = _sessions.get(sid)
return list(s["files"]) if s else None
def file_count(sid: str) -> int:
"""Количество файлов в сессии."""
with _lock:
s = _sessions.get(sid)
return len(s["files"]) if s else 0
+45
View File
@@ -0,0 +1,45 @@
"""Общее состояние сессий: хранилище, блокировка, константы, TTL-таймер.
Единая точка хранения состояния — все операции импортируют её.
Дробить state.py на файл-на-переменную не нужно: это данные, а не функции.
"""
import threading
# TTL сессии: 30 минут
TTL_SECONDS = 30 * 60
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
_sessions: dict = {}
_lock = threading.Lock()
def _start_timer(sid: str) -> threading.Timer:
"""Запустить таймер автоочистки сессии через TTL."""
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return timer
def configure(max_file_bytes: int = None, max_session_bytes: int = None,
ttl_seconds: int = None):
"""Переопределить лимиты/TTL из конфига приложения (глобально).
None — оставить текущее значение.
"""
global MAX_FILE_BYTES, MAX_SESSION_BYTES, TTL_SECONDS
if max_file_bytes is not None:
MAX_FILE_BYTES = max_file_bytes
if max_session_bytes is not None:
MAX_SESSION_BYTES = max_session_bytes
if ttl_seconds is not None:
TTL_SECONDS = ttl_seconds
+30
View File
@@ -0,0 +1,30 @@
"""store_csv / get_csv — сохранение и чтение CSV с таблицей замен."""
from typing import Optional
from .state import _sessions, _lock
def store_csv(sid: str, csv_str: str) -> bool:
"""Сохранить CSV с таблицей замен.
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["csv"] = csv_str
return True
def get_csv(sid: str) -> Optional[str]:
"""Получить CSV с таблицей замен.
Returns:
Строка CSV или None если нет.
"""
with _lock:
s = _sessions.get(sid)
return s.get("csv") if s else None
+30
View File
@@ -0,0 +1,30 @@
"""store_result / get_result — сохранение и чтение результата обработки."""
from typing import Optional
from .state import _sessions, _lock
def store_result(sid: str, zip_data: bytes) -> bool:
"""Сохранить результат обработки (ZIP-архив).
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["result"] = zip_data
return True
def get_result(sid: str) -> Optional[bytes]:
"""Получить результат обработки.
Returns:
ZIP-архив или None если сессия не найдена/результат не готов.
"""
with _lock:
s = _sessions.get(sid)
return s["result"] if s else None
+34
View File
@@ -0,0 +1,34 @@
"""touch / pause_ttl / resume_ttl — управление TTL-таймером сессии."""
from .state import _sessions, _lock, _start_timer
def touch(sid: str):
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def pause_ttl(sid: str):
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
with _lock:
s = _sessions.get(sid)
if s and s.get("timer"):
s["timer"].cancel()
s["timer"] = None
def resume_ttl(sid: str):
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
+20
View File
@@ -0,0 +1,20 @@
"""Слой 2 (бэк): переиспользуемый Blueprint закачки через ВМ.
Использование:
from upload.backend.upload_refs import create_upload_refs_blueprint
app.register_blueprint(create_upload_refs_blueprint(cfg))
"""
from .blueprint import create_upload_refs_blueprint
from .safe_name import safe_name
from .pull_file import pull_file
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
__all__ = [
"create_upload_refs_blueprint",
"safe_name",
"pull_file",
"PULL_RETRIES",
"PULL_RETRY_DELAY",
"VM_UPLOAD_PREFIX",
]
+160
View File
@@ -0,0 +1,160 @@
"""Переиспользуемый Blueprint слоя 2: POST /upload_refs (pull с ВМ-буфера в сессию).
Поведение 1:1 с drhider v0.0.75 (site/routes/api_bp.py):
- _safe_name (path traversal)
- SSRF-валидация url.startswith(VM_UPLOAD_PREFIX)
- лимит на один файл -> delete+skip
- pull с ретраями
- лимит сессии -> skip; отсутствие сессии -> 404
- delete url с ВМ (best-effort)
"""
import httpx
import logging
from flask import Blueprint, request, jsonify
from ..session import (create_session, add_file, get_files, file_count,
MAX_FILE_BYTES, configure)
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
from .safe_name import safe_name
from .pull_file import pull_file
log = logging.getLogger("upload.upload_refs")
def create_upload_refs_blueprint(cfg: dict, sink=None) -> Blueprint:
"""Создать Blueprint с эндпоинтом upload_refs.
cfg (все ключи опциональны, есть дефолты):
apiPrefix (str) — префикс Blueprint, по умолчанию "/api"
vmUploadPrefix (str) — доверенный префикс ВМ-буфера (SSRF-валидация)
maxFileBytes (int) — лимит на один файл
maxSessionBytes (int) — суммарный лимит сессии (применяется к сессиям)
ttlSeconds (int) — TTL сессии
pullRetries (int) — ретраи pull
pullRetryDelay (int) — пауза между ретраями (сек)
pullTimeout (int) — таймаут одного GET pull
sink (callable | None): если задан — вместо add_file в in-memory сессию
вызывается sink(name, content, **extra) на каждый вытянутый файл,
endpoint возвращает {"ok": true, "results": [...]}. extra — сквозные
поля тела запроса (batch_id, contract_id, zip_source). Если None —
прежнее поведение drhider (in-memory сессия).
"""
prefix = cfg.get("apiPrefix", "/api")
vm_prefix = cfg.get("vmUploadPrefix", VM_UPLOAD_PREFIX)
max_file_bytes = cfg.get("maxFileBytes", MAX_FILE_BYTES)
pull_retries = cfg.get("pullRetries", PULL_RETRIES)
pull_delay = cfg.get("pullRetryDelay", PULL_RETRY_DELAY)
pull_timeout = cfg.get("pullTimeout", 120)
# Применить лимиты сессии/TTL из конфига (глобально для всех сессий)
configure(
max_file_bytes=cfg.get("maxFileBytes"),
max_session_bytes=cfg.get("maxSessionBytes"),
ttl_seconds=cfg.get("ttlSeconds"),
)
bp = Blueprint("upload_refs", __name__, url_prefix=prefix)
@bp.route("/upload_refs", methods=["POST"])
def upload_refs():
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
Каждый файл тянется ИСХОДЯЩИМ GET'ом с ВМ (egress не ограничен шлюзом),
читается по частям (stream), кладётся в сессию. После успешного pull файл
удаляется с ВМ (best-effort; TTL-чистка на ВМ тоже есть).
"""
data = request.get_json(silent=True) or {}
sid = data.get("session") or create_session()
refs = data.get("files") or []
if not refs:
log.warning("upload_refs: no files, sid=%s", sid)
return jsonify({"ok": False, "error": "No files"}), 400
extra = {k: data[k] for k in ("batch_id", "contract_id", "zip_source") if data.get(k) is not None}
results = [] if sink else None
added = 0
try:
with httpx.Client(timeout=pull_timeout, follow_redirects=True) as client:
for ref in refs:
name = safe_name(ref.get("name") or "")
url = ref.get("url")
if not name or not url:
continue
# SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера
if not url.startswith(vm_prefix):
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
if sink:
results.append({"name": name, "ok": False, "error": "invalid url (SSRF guard)"})
continue
# Лимит на один файл: сверх лимита — пропускаем (не участвует)
if (ref.get("size") or 0) > max_file_bytes:
log.warning("upload_refs: file exceeds %dMB, skip sid=%s file=%r size=%s",
max_file_bytes // (1024 * 1024), sid, name, ref.get("size"))
try:
client.delete(url)
except Exception:
pass
if sink:
results.append({"name": name, "ok": False, "error": "file too large"})
continue
# Pull с ретраями: разовые DNS/сетевые сбои не роняют всю загрузку
try:
content = pull_file(client, url, pull_retries, pull_delay, sid=sid, name=name)
except Exception as e:
log.warning("upload_refs: pull failed sid=%s file=%r: %r", sid, name, e)
if sink:
results.append({"name": name, "ok": False, "error": "pull failed: %s" % e})
continue
log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content))
if len(content) > max_file_bytes:
log.warning("upload_refs: pulled file exceeds %dMB, skip sid=%s file=%r size=%d",
max_file_bytes // (1024 * 1024), sid, name, len(content))
try:
client.delete(url)
except Exception:
pass
if sink:
results.append({"name": name, "ok": False, "error": "file too large"})
continue
if sink:
# Отдать файл приложению через sink (вместо in-memory сессии)
try:
client.delete(url)
except Exception:
pass
try:
r = sink(name, content, **extra) or {}
results.append({"name": name, **r})
except Exception as e:
log.error("upload_refs: sink failed file=%r: %r", name, e)
results.append({"name": name, "ok": False, "error": str(e)})
continue
if not add_file(sid, name, content):
# Различить: сессия исчезла vs превышен суммарный лимит сессии
if get_files(sid) is None:
log.warning("upload_refs: session not found, sid=%s file=%r", sid, name)
return jsonify({"ok": False, "error": "Session not found"}), 404
log.warning("upload_refs: session limit exceeded, skip sid=%s file=%r", sid, name)
try:
client.delete(url)
except Exception:
pass
continue
try:
client.delete(url) # убрать файл с ВМ после загрузки
except Exception:
pass
added += 1
except Exception as e:
log.error("upload_refs: pull error sid=%s: %r", sid, e)
return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502
if sink:
return jsonify({"ok": True, "results": results})
log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid))
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
return bp
+11
View File
@@ -0,0 +1,11 @@
"""Параметры слоя 2 (бэк) по умолчанию.
Переопределяются из конфига приложения через create_upload_refs_blueprint(cfg).
"""
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
PULL_RETRIES = 3
PULL_RETRY_DELAY = 2 # секунды между попытками
# Доверенный префикс ВМ-буфера — валидация URL при pull (защита от SSRF)
VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
+39
View File
@@ -0,0 +1,39 @@
"""pull_file — вытащить файл с ВМ-буфера исходящим GET с ретраями."""
import logging
import time
from .config import PULL_RETRIES, PULL_RETRY_DELAY
log = logging.getLogger("upload.upload_refs.pull")
def pull_file(client, url: str, retries: int = PULL_RETRIES,
delay: float = PULL_RETRY_DELAY, sid: str = None, name: str = None) -> bytes:
"""GET url с ретраями; читает по частям (stream).
Args:
client: httpx.Client
url: URL файла на ВМ-буфере.
retries: число попыток.
delay: пауза между попытками (сек).
sid/name: для логирования (опционально).
Returns:
Содержимое файла (bytes).
Raises:
Последнюю ошибку попытки, если все ретраи не удались.
"""
last_err = None
for attempt in range(retries):
try:
with client.stream("GET", url) as resp:
resp.raise_for_status()
return b"".join(resp.iter_bytes())
except Exception as e:
last_err = e
log.warning("pull: attempt %d/%d failed sid=%s file=%r: %r",
attempt + 1, retries, sid, name, e)
time.sleep(delay)
raise last_err if last_err else RuntimeError("pull failed")
+16
View File
@@ -0,0 +1,16 @@
"""safe_name — санитизация имени файла (защита от path traversal)."""
def safe_name(name: str) -> str:
"""Санитизировать имя файла: защита от path traversal, сохраняя подпапки.
Запрещает '..' и абсолютные пути; нормализует слэши. Возвращает "" если
имя пустое или небезопасное.
"""
if not name:
return ""
name = name.replace("\\", "/")
parts = [p for p in name.split("/") if p and p != "."]
if not parts or any(p == ".." for p in parts):
return ""
return "/".join(parts)
+13
View File
@@ -0,0 +1,13 @@
{
"vmUploadUrl": "https://contracts.kube5s.ru/drhider-upload/",
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
"maxFileBytes": 52428800,
"maxSessionBytes": 524288000,
"apiPrefix": "/api",
"vmUploadPrefix": "https://contracts.kube5s.ru/drhider-upload/",
"pullRetries": 3,
"pullRetryDelay": 2,
"pullTimeout": 120,
"ttlSeconds": 1800,
"estMbSec": 12
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "upload-frontend",
"private": true,
"type": "module",
"description": "Переиспользуемый фронт слоёв 1 и 2 (выбор файлов + закачка через ВМ). Модули ES — подключаются в браузере через <script type=\"module\">; Node-режим нужен для юнит-тестов zip."
}
@@ -0,0 +1,47 @@
// addFileWithDedup — дедуп «имя+размер», суффиксы _2/_3, лимиты (over).
// Мутирует state. Возвращает true если файл добавлен (или переведён в over),
// false если это дедуп (обновлена только дата).
export function addFileWithDedup(state, file, cfg) {
const size = file.size;
const mtime = file.lastModified;
let name = file.name;
const maxFileBytes = cfg.maxFileBytes;
const maxSessionBytes = cfg.maxSessionBytes;
if (state.fileMeta.has(name)) {
const e = state.fileMeta.get(name);
if (e.size === size) {
// Тот же файл (имя+размер) — дедуп. Дату не сравниваем: файл могли пересохранить
// с тем же содержимым. Оставляем более свежий по дате.
if (mtime > e.mtime) {
e.mtime = mtime;
const idx = state.files.findIndex(f => f.name === name);
if (idx >= 0) state.files[idx] = new File([file], name, { lastModified: mtime });
}
return false; // дедуп: файл не добавлен (обновлена только дата)
}
// Имя то же, размер другой — добавить с суффиксом _2, _3...
const dot = name.lastIndexOf('.');
const base = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : '';
let n = 2;
while (state.fileMeta.has(base + '_' + n + ext)) n++;
name = base + '_' + n + ext;
}
state.fileMeta.set(name, { size, mtime });
// Определяем, превышает ли файл лимит (по размеру файла или суммарный) — не участвует в обфускации
let over = false;
if (size > maxFileBytes) over = true; // лимит на один файл
const sum = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : f.size), 0);
if (sum + size > maxSessionBytes) over = true; // суммарный лимит сессии
if (over) {
state.overNames.add(name);
state.files.push(new File([file], name, { lastModified: mtime }));
return true;
}
state.files.push(new File([file], name, { lastModified: mtime }));
return true;
}
+5
View File
@@ -0,0 +1,5 @@
// esc — экранирование HTML (защита от self-XSS именами файлов).
export function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
+8
View File
@@ -0,0 +1,8 @@
// estForFile — эмпирическая оценка времени обработки файла: сек/МБ (ориентировочно, до старта).
export const DEFAULT_EST_MB_SEC = 12;
export function estForFile(f, estMbSec) {
const k = estMbSec || DEFAULT_EST_MB_SEC;
return f ? Math.max(1, Math.round(f.size / 1048576 * k)) : 0;
}
+6
View File
@@ -0,0 +1,6 @@
// fmtSec — формат секунд: "Nс" / "Nм Nс".
export function fmtSec(s) {
s = Math.max(0, Math.round(s));
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
}
+7
View File
@@ -0,0 +1,7 @@
// fs — формат размера: B / KB / MB.
export function fs(b) {
return b < 1024 ? b + ' B'
: b < 1048576 ? (b / 1024).toFixed(1) + ' KB'
: (b / 1048576).toFixed(1) + ' MB';
}
@@ -0,0 +1,56 @@
// initUploadTable — сборка слоя 1 (выбор файлов/папки/архива).
// Состояние (files/fileMeta/overNames) живёт внутри модуля.
import { addFiles as addFilesToState, makeFilesChangeHandler } from './on_files_change.js';
import { makeFolderChangeHandler } from './on_folder_change.js';
import { render } from './render.js';
import { setStatus } from './set_status.js';
import { rmFile } from './rm_file.js';
// cfg: {allowedExt, maxFileBytes, maxSessionBytes, estMbSec}
// els: {fileInput, folderInput, tableBody, countEl, uploadBtnEl, onStatus(cls, text)}
// returns api (см. README.md)
export function initUploadTable(cfg, els) {
const state = {
files: [], // File[]
fileMeta: new Map(), // имя -> {size, mtime} для дедупа/суффиксов
overNames: new Set(), // имена файлов сверх лимита (не участвуют)
busy: false, // идёт загрузка/обработка — список заблокирован
proc: null, // {phase, procState, procExtractRate} — задаёт слой 3
};
const onFilesChange = makeFilesChangeHandler({ state, cfg, els, onStatus: els.onStatus });
const onFolderChange = makeFolderChangeHandler({ state, cfg, els, onStatus: els.onStatus });
els.fileInput.addEventListener('change', onFilesChange);
els.folderInput.addEventListener('change', onFolderChange);
// Делегирование кликов по кнопкам «✕» (удалить строку)
els.tableBody.addEventListener('click', e => {
const btn = e.target.closest('.remove-btn');
if (btn) rmFile(state, els, cfg, Number(btn.dataset.idx));
});
return {
state, // доступ для слоя 3 (установить state.proc для render)
addFiles(files) { return addFilesToState(state, cfg, files, els, els.onStatus); },
getFiles() { // ТОЛЬКО учитываемые (без over): [{name, size, file}]
return state.files.filter(f => !state.overNames.has(f.name))
.map(f => ({ name: f.name, size: f.size, file: f }));
},
getOverNames() { return state.overNames; },
setStatus(idx, html) { setStatus(idx, html); },
render() { render(state, els, cfg); },
clear() {
state.files = [];
state.fileMeta = new Map();
state.overNames = new Set();
if (els.fileInput) els.fileInput.value = '';
render(state, els, cfg);
},
setBusy(b) {
state.busy = b;
if (els.fileInput) els.fileInput.disabled = b;
},
_rm(i) { rmFile(state, els, cfg, i); },
};
}
+56
View File
@@ -0,0 +1,56 @@
// Обработчик <input type="file" multiple> change: дедуп + раскрытие ZIP + фильтр.
// Экспортируется и чистая логика добавления массива файлов (addFiles),
// и фабрика обработчика для input (makeFilesChangeHandler).
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js';
// Добавить массив файлов в список (дедуп + раскрытие ZIP + лимиты).
// files: File[]. els.fileInput — для синхронизации input.files (DataTransfer),
// чтобы повторный выбор того же файла сработал. onStatus(cls, text) — колбэк сообщений.
export async function addFiles(state, cfg, files, els, onStatus) {
const incoming = Array.from(files);
const hasZip = incoming.some(f => f.name.toLowerCase().endsWith('.zip'));
if (hasZip) {
document.body.style.cursor = 'wait';
if (onStatus) onStatus('progress', 'Разбираю архивы…');
}
try {
for (const f of incoming) {
if (f.name.toLowerCase().endsWith('.zip')) {
try {
const nested = await listZipFiles(f, cfg.allowedExt);
if (nested.length) nested.forEach(x => addFileWithDedup(state, x, cfg));
else addFileWithDedup(state, f, cfg); // в архиве нет документов — архив как есть
} catch (err) {
addFileWithDedup(state, f, cfg); // не удалось распаковать — zip как есть
}
} else {
addFileWithDedup(state, f, cfg);
}
}
} finally {
if (hasZip) {
document.body.style.cursor = '';
if (onStatus) onStatus('', '');
}
}
// Синхронизировать input.files — чтобы повторный выбор того же файла сработал
if (els && els.fileInput && els.fileInput.files) {
const d = new DataTransfer();
state.files.forEach(f => d.items.add(f));
els.fileInput.files = d.files;
}
render(state, els, cfg);
}
// Фабрика обработчика change для <input type="file">.
// ctx = { state, cfg, els, onStatus }
export function makeFilesChangeHandler(ctx) {
const { state, cfg, els, onStatus } = ctx;
return () => {
if (state.busy) return; // во время загрузки/обработки менять список нельзя
addFiles(state, cfg, els.fileInput.files, els, onStatus);
};
}
+58
View File
@@ -0,0 +1,58 @@
// Обработчик <input webkitdirectory> change: выбор целой папки, рекурсивно,
// относительный путь сохраняется (верхняя папка отбрасывается).
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js';
// Фабрика обработчика change для input выбора папки.
// ctx = { state, cfg, els, onStatus }
export function makeFolderChangeHandler(ctx) {
const { state, cfg, els, onStatus } = ctx;
return async () => {
if (state.busy) return; // во время загрузки/обработки менять список нельзя
const incoming = Array.from(els.folderInput.files);
if (!incoming.length) return;
document.body.style.cursor = 'wait';
if (onStatus) onStatus('progress', 'Разбираю папку…');
let added = 0;
try {
for (const f of incoming) {
// webkitRelativePath: "TopFolder/Подпапка/file.pdf" — отбрасываем верхнюю папку
const parts = (f.webkitRelativePath || f.name).split('/');
const rel = parts.slice(1).join('/') || f.name;
const low = rel.toLowerCase();
const slashIdx = rel.lastIndexOf('/');
const relDir = slashIdx >= 0 ? rel.slice(0, slashIdx) : '';
if (low.endsWith('.zip')) {
try {
const nested = await listZipFiles(f, cfg.allowedExt);
if (nested.length) {
for (const nf of nested) {
const nm = relDir ? relDir + '/' + nf.name : nf.name;
if (addFileWithDedup(state, new File([nf], nm, { lastModified: nf.lastModified }), cfg)) added++;
}
} else {
// в архиве нет документов — добавить архив как есть, чтобы не терялся
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++;
}
} catch (err) {
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++; // zip как есть
}
} else if (cfg.allowedExt.some(e => low.endsWith(e))) {
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++;
}
// иначе — не документ, пропускаем
}
} finally {
document.body.style.cursor = '';
}
els.folderInput.value = ''; // чтобы повторный выбор той же папки сработал
render(state, els, cfg);
if (added && onStatus) {
const n = added;
const w = (n % 10 === 1 && n % 100 !== 11) ? 'файл' : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 'файла' : 'файлов';
onStatus('done', '✅ Добавлено из папки: ' + n + ' ' + w);
}
};
}
+13
View File
@@ -0,0 +1,13 @@
// procRow — строка таблицы обработки (3-секционная).
import { esc } from './esc.js';
import { fs } from './fs.js';
export function procRow(state, i, stTxt) {
const f = state.files[i];
const over = state.overNames.has(f.name);
const p = state.proc && state.proc.procState ? state.proc.procState[i] : null;
const cls = (p && p.st === 'current') ? ' class="row-current"'
: (over ? ' class="row-over"' : '');
return '<tr' + cls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" style="font-size:12px;">' + stTxt + '</td><td></td></tr>';
}
+29
View File
@@ -0,0 +1,29 @@
// render — рендер таблицы выбора файлов (обычная).
// Если идёт обработка (state.proc.phase === 'processing') — 3-секционная.
import { esc } from './esc.js';
import { fs } from './fs.js';
import { fmtSec } from './fmt_sec.js';
import { estForFile } from './est_for_file.js';
import { renderProcTable } from './render_proc_table.js';
export function render(state, els, cfg) {
if (state.proc && state.proc.phase === 'processing') { renderProcTable(state, els, cfg); return; }
if (state.files.length === 0) {
els.tableBody.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>';
} else {
els.tableBody.innerHTML = state.files.map((f, i) => {
const over = state.overNames.has(f.name);
const rowCls = over ? ' class="row-over"' : '';
const stTxt = over ? '<span style="color:#c0392b;">🔥 не учитывается</span>'
: '<span style="color:#7d3c98;">~' + fmtSec(estForFile(f, cfg.estMbSec)) + '</span>';
return '<tr id="row-' + i + '"' + rowCls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" id="st-' + i + '" style="font-size:12px;">' + stTxt + '</td><td><button class="remove-btn" data-idx="' + i + '">✕</button></td></tr>';
}).join('');
}
const overCount = state.files.filter(f => state.overNames.has(f.name)).length;
const totalSize = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : f.size), 0);
const cntMain = state.files.length - overCount;
const totEst = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : estForFile(f, cfg.estMbSec)), 0);
els.countEl.textContent = (overCount ? cntMain + ' учитываются + ' + overCount + ' свыше лимита' : state.files.length) + ' файлов · ' + fs(totalSize) + ' · ~' + fmtSec(totEst);
els.uploadBtnEl.disabled = cntMain === 0;
}
@@ -0,0 +1,74 @@
// renderProcTable — 3-секционная таблица во время обработки
// (готово / текущий / ожидают / пропущены сверх лимита).
import { fmtSec } from './fmt_sec.js';
import { estForFile, DEFAULT_EST_MB_SEC } from './est_for_file.js';
import { procRow } from './proc_row.js';
export function renderProcTable(state, els, cfg) {
const procState = state.proc.procState;
const groups = { done: [], current: [], pending: [], over: [] };
for (let i = 0; i < state.files.length; i++) {
if (state.overNames.has(state.files[i].name)) { groups.over.push(i); continue; }
const st = procState[i] ? procState[i].st : 'pending';
if (st === 'done' || st === 'skipped') groups.done.push(i);
else if (st === 'current') groups.current.push(i);
else groups.pending.push(i);
}
// Оценка скорости из текущего файла (сек/символ) — для «ожидающих»
let rate = null;
for (const i of groups.current) {
const p = procState[i];
const cur = (performance.now() - p.t0) / 1000;
const total = cur + (p.eta != null ? p.eta : 0);
if (p.chars > 0 && total > 0) rate = total / p.chars;
}
const rows = [];
if (groups.done.length) {
rows.push('<tr class="grp-row"><td colspan="4">✓ Обработанные (' + groups.done.length + ')</td></tr>');
for (const i of groups.done) {
const p = procState[i];
const txt = p.st === 'skipped'
? '<span style="color:#c0392b;" title="Не удалось прочитать файл: пустой, повреждённый или скан без текста">не извлечён</span>'
: '<span style="color:#22c55e;">✓ ' + (p.elapsed ? p.elapsed.toFixed(1) : '0.0') + 'с</span>';
rows.push(procRow(state, i, txt));
}
}
if (groups.over.length) {
rows.push('<tr class="grp-row"><td colspan="4">⛔ Пропущены (сверх лимита) (' + groups.over.length + ')</td></tr>');
for (const i of groups.over) {
rows.push(procRow(state, i, '<span style="color:#c0392b;">пропущен (лимит)</span>'));
}
}
if (groups.current.length) {
rows.push('<tr class="grp-row"><td colspan="4">▶ Текущий файл</td></tr>');
for (const i of groups.current) {
const p = procState[i];
const cur = ((performance.now() - p.t0) / 1000).toFixed(1);
const eta = (p.eta != null) ? ' / ~' + fmtSec(p.eta) : '';
rows.push(procRow(state, i, '<span style="color:#2563eb;">⏳ ' + cur + 'с' + eta + '</span>'));
}
}
if (groups.pending.length) {
rows.push('<tr class="grp-row"><td colspan="4">○ Ожидают обработки (' + groups.pending.length + ')</td></tr>');
for (const i of groups.pending) {
const p = procState[i] || {};
// Оценка: LLM (chars x rate) если известна; иначе грубая по размеру/скорости извлечения
if (rate && !p.est && p.chars > 0) p.est = p.chars * rate;
if (!p.est) {
const szMB = (state.files[i] ? state.files[i].size : 0) / 1048576;
const k = (state.proc && state.proc.procExtractRate) || cfg.estMbSec || DEFAULT_EST_MB_SEC; // сек/МБ
p.est = Math.max(1, Math.round(szMB * k));
}
let txt;
if (p.st === 'analyzed') txt = '<span style="color:#7d3c98;">анализ ✓</span>';
else if (p.st === 'current') txt = '<span style="color:#2563eb;">⏳</span>';
else if (p.est != null) txt = '<span style="color:#999;">~' + fmtSec(p.est) + '</span>';
else txt = '<span style="color:#999;">—</span>';
rows.push(procRow(state, i, txt));
}
}
els.tableBody.innerHTML = rows.join('');
const cntDone = groups.done.length;
els.countEl.textContent = 'Готово ' + cntDone + ' / ' + state.files.length + ' файлов';
}
+19
View File
@@ -0,0 +1,19 @@
// rmFile — удалить файл из списка (если не busy).
import { render } from './render.js';
export function rmFile(state, els, cfg, i) {
if (state.busy) return;
const nm = state.files[i].name;
state.overNames.delete(nm);
state.fileMeta.delete(nm);
state.files.splice(i, 1);
// Синхронизировать input.files (DataTransfer) — чтобы повторный выбор того же
// файла сработал (change не сработает, если files не обновлён).
if (els.fileInput && els.fileInput.files) {
const d = new DataTransfer();
state.files.forEach(f => d.items.add(f));
els.fileInput.files = d.files;
}
render(state, els, cfg);
}
+6
View File
@@ -0,0 +1,6 @@
// setStatus — записать HTML-статус в ячейку таблицы (st-<idx>).
export function setStatus(idx, html) {
const e = document.getElementById('st-' + idx);
if (e) e.innerHTML = html;
}
+344
View File
@@ -0,0 +1,344 @@
// Юнит-тесты фронта модуля upload: zip (кириллица, вложенный zip, не-doc) + дедуп/лимиты.
// Запуск: node upload/frontend/test_upload_frontend.mjs
import assert from 'node:assert/strict';
import { deflateRawSync } from 'node:zlib';
// ── Полифилл File (Node 18 не имеет global File) ──
if (typeof globalThis.File === 'undefined') {
globalThis.File = class File extends Blob {
constructor(parts, name, opts) {
super(parts, opts);
this.name = name;
this.lastModified = (opts && opts.lastModified) || Date.now();
}
};
}
import { listZipFiles } from './zip/list_zip_files.js';
import { parseZip } from './zip/parse_zip.js';
import { decodeZipName } from './zip/decode_zip_name.js';
import { addFileWithDedup } from './table/add_file_with_dedup.js';
import { esc } from './table/esc.js';
import { fs } from './table/fs.js';
import { fmtSec } from './table/fmt_sec.js';
import { estForFile } from './table/est_for_file.js';
const ALLOWED = ['.pdf', '.doc', '.docx', '.txt', '.md'];
// ── CRC32 (для сборки тестовых ZIP) ──
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
// ── Сборка минимального ZIP (method 0, UTF-8-флаг) ──
function makeZip(entries) {
const enc = new TextEncoder();
const chunks = [];
const central = [];
let offset = 0;
const utf8Flag = 0x0800;
for (const e of entries) {
const nameB = enc.encode(e.name);
const dataB = typeof e.data === 'string' ? enc.encode(e.data) : e.data;
const crc = crc32(dataB);
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0, 0x04034b50, true);
lh.setUint16(4, 20, true);
lh.setUint16(6, utf8Flag, true);
lh.setUint16(8, 0, true);
lh.setUint16(10, 0, true);
lh.setUint16(12, 0x21, true);
lh.setUint32(14, crc, true);
lh.setUint32(18, dataB.length, true);
lh.setUint32(22, dataB.length, true);
lh.setUint16(26, nameB.length, true);
lh.setUint16(28, 0, true);
chunks.push(Buffer.from(lh.buffer), Buffer.from(nameB), Buffer.from(dataB));
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(4, 20, true);
cd.setUint16(6, 20, true);
cd.setUint16(8, utf8Flag, true);
cd.setUint16(10, 0, true);
cd.setUint16(12, 0, true);
cd.setUint16(14, 0x21, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, dataB.length, true);
cd.setUint32(24, dataB.length, true);
cd.setUint16(28, nameB.length, true);
cd.setUint16(30, 0, true);
cd.setUint16(32, 0, true);
cd.setUint16(34, 0, true);
cd.setUint16(36, 0, true);
cd.setUint32(38, 0, true);
cd.setUint32(42, offset, true);
central.push(Buffer.from(cd.buffer), Buffer.from(nameB));
offset += 30 + nameB.length + dataB.length;
}
const cdSize = central.reduce((s, x) => s + x.byteLength, 0);
const cdStart = offset;
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(4, 0, true);
eocd.setUint16(6, 0, true);
eocd.setUint16(8, entries.length, true);
eocd.setUint16(10, entries.length, true);
eocd.setUint32(12, cdSize, true);
eocd.setUint32(16, cdStart, true);
eocd.setUint16(20, 0, true);
chunks.push(...central, Buffer.from(eocd.buffer));
return Buffer.concat(chunks);
}
// ── Сборка ZIP с методом 8 (deflate) — для проверки inflateRaw ──
async function deflateRaw(bytes) {
// zlib.deflateRawSync — deflate без zlib-заголовка (то, что ждёт inflateRaw)
return deflateRawSync(Buffer.from(bytes));
}
async function makeZipDeflate(entries) {
const enc = new TextEncoder();
const chunks = [];
const central = [];
let offset = 0;
const utf8Flag = 0x0800;
for (const e of entries) {
const nameB = enc.encode(e.name);
const raw = enc.encode(e.data);
const comp = await deflateRaw(raw);
const crc = crc32(raw);
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0, 0x04034b50, true);
lh.setUint16(4, 20, true);
lh.setUint16(6, utf8Flag, true);
lh.setUint16(8, 8, true); // method 8 deflate
lh.setUint16(10, 0, true);
lh.setUint16(12, 0x21, true);
lh.setUint32(14, crc, true);
lh.setUint32(18, comp.length, true);
lh.setUint32(22, raw.length, true);
lh.setUint16(26, nameB.length, true);
lh.setUint16(28, 0, true);
chunks.push(Buffer.from(lh.buffer), Buffer.from(nameB), Buffer.from(comp));
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(4, 20, true);
cd.setUint16(6, 20, true);
cd.setUint16(8, utf8Flag, true);
cd.setUint16(10, 8, true);
cd.setUint16(12, 0, true);
cd.setUint16(14, 0x21, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, comp.length, true);
cd.setUint32(24, raw.length, true);
cd.setUint16(28, nameB.length, true);
cd.setUint16(30, 0, true);
cd.setUint16(32, 0, true);
cd.setUint16(34, 0, true);
cd.setUint16(36, 0, true);
cd.setUint32(38, 0, true);
cd.setUint32(42, offset, true);
central.push(Buffer.from(cd.buffer), Buffer.from(nameB));
offset += 30 + nameB.length + comp.length;
}
const cdSize = central.reduce((s, x) => s + x.byteLength, 0);
const cdStart = offset;
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(4, 0, true);
eocd.setUint16(6, 0, true);
eocd.setUint16(8, entries.length, true);
eocd.setUint16(10, entries.length, true);
eocd.setUint32(12, cdSize, true);
eocd.setUint32(16, cdStart, true);
eocd.setUint16(20, 0, true);
chunks.push(...central, Buffer.from(eocd.buffer));
return Buffer.concat(chunks);
}
// ── Тесты decodeZipName ──
function testDecodeZipName() {
const utf8 = new TextEncoder().encode('договор.pdf');
assert.equal(decodeZipName(utf8, true), 'договор.pdf');
// Без UTF-8-флага, но байты — валидный UTF-8 с кириллицей → эвристика берёт как есть
assert.equal(decodeZipName(utf8, false), 'договор.pdf');
console.log(' decodeZipName: ok');
}
// ── Тесты listZipFiles ──
async function testZipCyrillic() {
const zip = makeZip([{ name: 'договор.pdf', data: '%PDF-1.4 fake' }]);
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
assert.equal(files.length, 1);
assert.equal(files[0].name, 'договор.pdf');
console.log(' zip кириллица: ok');
}
async function testZipNested() {
const inner = makeZip([{ name: 'inner.txt', data: 'hi' }]);
const outer = makeZip([
{ name: 'inner.zip', data: inner },
{ name: 'skip.txt', data: 'x' },
]);
const files = await listZipFiles(new File([outer], 'outer.zip'), ALLOWED);
const names = files.map(f => f.name).sort();
assert.deepEqual(names, ['inner.txt', 'skip.txt']);
console.log(' вложенный zip: ok');
}
async function testZipNonDoc() {
const zip = makeZip([
{ name: 'doc.pdf', data: 'pdf' },
{ name: 'img.png', data: 'png' },
{ name: 'readme.txt', data: 'readme' },
]);
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
const names = files.map(f => f.name).sort();
assert.deepEqual(names, ['doc.pdf', 'readme.txt']);
console.log(' не-doc фильтр: ok');
}
async function testZipDeflate() {
// deflate-raw требует DecompressionStream('deflate-raw') — в браузере есть, в Node 18 нет
let supported = true;
try { new DecompressionStream('deflate-raw'); } catch (e) { supported = false; }
if (!supported) {
console.log(' zip deflate (метод 8): skip — Node 18 не поддерживает deflate-raw');
return;
}
const content = 'Hello deflate world '.repeat(50);
const zip = await makeZipDeflate([{ name: 'deflated.txt', data: content }]);
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
assert.equal(files.length, 1);
assert.equal(files[0].name, 'deflated.txt');
assert.equal(await files[0].text(), content);
console.log(' zip deflate (метод 8): ok');
}
async function testParseZipNotZip() {
await assert.rejects(() => parseZip(new Uint8Array([1, 2, 3])), /Не ZIP/);
console.log(' не-ZIP бросок: ok');
}
// ── Тесты addFileWithDedup ──
function newState() {
return { files: [], fileMeta: new Map(), overNames: new Set(), busy: false, proc: null };
}
function testDedup() {
const st = newState();
const c = { maxFileBytes: 100, maxSessionBytes: 300 };
// одинаковое имя+размер → дедуп (false), список не растёт
assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), true);
assert.equal(st.files.length, 1);
assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), false);
assert.equal(st.files.length, 1);
// имя то же, размер другой → суффикс _2
assert.equal(addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c), true);
assert.equal(st.files.length, 2);
assert.equal(st.files[1].name, 'a_2.txt');
console.log(' дедуп/суффиксы: ok');
}
function testLimits() {
// лимит на один файл
const st = newState();
const c = { maxFileBytes: 100, maxSessionBytes: 300 };
addFileWithDedup(st, new File([new Uint8Array(150)], 'big.bin'), c);
assert.ok(st.overNames.has('big.bin'));
assert.equal(st.files.length, 1);
// суммарный лимит сессии
const st2 = newState();
const c2 = { maxFileBytes: 1000, maxSessionBytes: 200 };
addFileWithDedup(st2, new File([new Uint8Array(150)], 'f1.bin'), c2);
addFileWithDedup(st2, new File([new Uint8Array(60)], 'f2.bin'), c2); // 150+60=210 > 200 → over
assert.ok(st2.overNames.has('f2.bin'));
console.log(' лимиты over: ok');
}
function testSuffixes() {
const st = newState();
const c = { maxFileBytes: 10000, maxSessionBytes: 100000 };
addFileWithDedup(st, new File(['aaa'], 'a.txt'), c);
addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c); // a_2.txt
addFileWithDedup(st, new File(['ccccc'], 'a.txt'), c); // a_3.txt
assert.deepEqual(st.files.map(f => f.name), ['a.txt', 'a_2.txt', 'a_3.txt']);
console.log(' суффиксы _2/_3: ok');
}
function testEsc() {
assert.equal(esc('<a href="x">&\'</a>'), '&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;');
console.log(' esc (XSS-экранирование): ok');
}
function testFs() {
assert.equal(fs(0), '0 B');
assert.equal(fs(1023), '1023 B');
assert.equal(fs(1024), '1.0 KB');
assert.equal(fs(1048576), '1.0 MB');
console.log(' fs (размер): ok');
}
function testFmtSec() {
assert.equal(fmtSec(0), '0с');
assert.equal(fmtSec(59), '59с');
assert.equal(fmtSec(60), '1м 0с');
assert.equal(fmtSec(125), '2м 5с');
console.log(' fmtSec: ok');
}
function testEstForFile() {
assert.equal(estForFile({ size: 1048576 }, 12), 12); // 1 МБ × 12 = 12с
assert.equal(estForFile(null, 12), 0);
console.log(' estForFile: ok');
}
// ── Прогон ──
const TESTS = [
['decodeZipName', testDecodeZipName],
['zip кириллица', testZipCyrillic],
['вложенный zip', testZipNested],
['не-doc фильтр', testZipNonDoc],
['zip deflate (метод 8)', testZipDeflate],
['не-ZIP бросок', testParseZipNotZip],
['дедуп/суффиксы', testDedup],
['лимиты over', testLimits],
['суффиксы _2/_3', testSuffixes],
['esc XSS', testEsc],
['fs размер', testFs],
['fmtSec', testFmtSec],
['estForFile', testEstForFile],
];
let failed = 0;
for (const [name, fn] of TESTS) {
try {
await fn();
console.log('PASS ' + name);
} catch (err) {
failed++;
console.error('FAIL ' + name + ': ' + err.message);
}
}
if (failed) {
console.error(failed + ' тестов упало');
process.exit(1);
}
console.log('Все фронт-тесты прошли');
+199
View File
@@ -0,0 +1,199 @@
// Юнит-тесты слоя 2 (фронт): putToVm + uploadViaVM с моками XMLHttpRequest и fetch.
// Запуск: node upload/frontend/test_upload_layer2.mjs
import assert from 'node:assert/strict';
// ── Полифилл File (Node 18 не имеет global File) ──
if (typeof globalThis.File === 'undefined') {
globalThis.File = class File extends Blob {
constructor(parts, name, opts) {
super(parts, opts);
this.name = name;
this.lastModified = (opts && opts.lastModified) || Date.now();
}
};
}
import { putToVm } from './upload/put_to_vm.js';
import { uploadViaVM } from './upload/upload_via_vm.js';
const tick = () => new Promise(r => setTimeout(r, 0));
// Node 18 не имеет globalThis.crypto (в браузере есть) — заглушка для uploadViaVM
globalThis.crypto = globalThis.crypto || { randomUUID: () => 'test-uuid-1234' };
// ── мок XMLHttpRequest ──
let lastXHR = null;
class FakeXHR {
constructor() {
this.upload = {};
this.status = 0;
this.timeout = 0;
this.onload = null;
this.onerror = null;
this.ontimeout = null;
this.method = null;
this.url = null;
this.body = null;
lastXHR = this;
}
open(method, url) { this.method = method; this.url = url; }
send(body) { this.body = body; }
}
globalThis.XMLHttpRequest = FakeXHR;
// ── мок fetch ──
let fetchCalls = [];
function installFetch(respond) {
fetchCalls = [];
globalThis.fetch = async (url, opts) => {
fetchCalls.push({ url, opts });
return respond();
};
}
// ── putToVm ──
async function testPutToVmSuccess() {
const f = new File(['abc'], 'a.txt');
const p = putToVm(f, 'https://vm/tok_0');
lastXHR.status = 200;
lastXHR.onload();
await p;
assert.equal(lastXHR.method, 'PUT');
assert.equal(lastXHR.url, 'https://vm/tok_0');
assert.equal(lastXHR.body, f);
console.log(' putToVm success: ok');
}
async function testPutToVmHttpError() {
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0');
lastXHR.status = 500;
lastXHR.onload();
await assert.rejects(p, /ВМ: HTTP 500/);
console.log(' putToVm HTTP error: ok');
}
async function testPutToVmNetworkError() {
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0');
lastXHR.onerror();
await assert.rejects(p, /Сеть/);
console.log(' putToVm network error: ok');
}
async function testPutToVmTimeout() {
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0');
lastXHR.ontimeout();
await assert.rejects(p, /Таймаут/);
console.log(' putToVm timeout: ok');
}
async function testPutToVmOnXHR() {
let registered = null;
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0', { onXHR(x) { registered = x; } });
assert.ok(registered, 'onXHR должен получить XHR для отмены');
registered.status = 200;
registered.onload();
await p;
console.log(' putToVm onXHR регистрация: ok');
}
// ── uploadViaVM ──
async function testUploadViaVMSuccess() {
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 'sid123', count: 2 }) }));
const files = [new File(['a'], 'a.txt'), new File(['bb'], 'b.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: '' });
await tick();
lastXHR.status = 200; lastXHR.onload();
await tick();
lastXHR.status = 200; lastXHR.onload();
const res = await p;
assert.equal(res.ok, true);
assert.equal(res.session, 'sid123');
assert.equal(res.count, 2);
assert.equal(fetchCalls.length, 1);
const body = JSON.parse(fetchCalls[0].opts.body);
assert.equal(body.files.length, 2);
assert.equal(body.files[0].name, 'a.txt');
assert.equal(body.files[1].name, 'b.txt');
assert.ok(body.files[0].url.startsWith('https://vm/'));
console.log(' uploadViaVM success: ok');
}
async function testUploadViaVMPutError() {
installFetch(() => ({ ok: true, json: async () => ({}) }));
const files = [new File(['a'], 'a.txt'), new File(['bb'], 'b.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: '' });
await tick();
lastXHR.onerror(); // первый PUT падает
const res = await p;
assert.equal(res.ok, false);
assert.ok(res.error.includes('Ошибка загрузки на ВМ'));
assert.equal(fetchCalls.length, 0); // POST не вызван
console.log(' uploadViaVM PUT error: ok');
}
async function testUploadViaVMPostError() {
installFetch(() => ({ ok: true, json: async () => ({ ok: false, error: 'Session not found' }) }));
const files = [new File(['a'], 'a.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: '' });
await tick();
lastXHR.status = 200; lastXHR.onload();
const res = await p;
assert.equal(res.ok, false);
assert.ok(res.error.includes('Ошибка передачи ссылок'));
console.log(' uploadViaVM POST error: ok');
}
async function testUploadViaVMProgress() {
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 's', count: 1 }) }));
const statuses = [];
const texts = [];
const files = [new File(['abc'], 'a.txt')];
const p = uploadViaVM(files, 'https://vm/', {
session: '',
onStatus(k, html) { statuses.push({ k, html }); },
onUploadStatus(t) { texts.push(t); },
});
await tick();
lastXHR.status = 200; lastXHR.onload();
await p;
assert.ok(statuses.some(s => s.html.includes('✓')));
assert.ok(texts.some(t => t.includes('Загрузка на ВМ')));
assert.ok(texts.some(t => t.includes('Передача ссылок')));
console.log(' uploadViaVM progress/статусы: ok');
}
async function testUploadViaVMSessionPassed() {
// session из options пробрасывается в POST-тело
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 'prev', count: 1 }) }));
const files = [new File(['a'], 'a.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: 'existingsid' });
await tick();
lastXHR.status = 200; lastXHR.onload();
await p;
const body = JSON.parse(fetchCalls[0].opts.body);
assert.equal(body.session, 'existingsid');
console.log(' uploadViaVM проброс session: ok');
}
// ── прогон ──
const TESTS = [
['putToVm success', testPutToVmSuccess],
['putToVm HTTP error', testPutToVmHttpError],
['putToVm network error', testPutToVmNetworkError],
['putToVm timeout', testPutToVmTimeout],
['putToVm onXHR', testPutToVmOnXHR],
['uploadViaVM success', testUploadViaVMSuccess],
['uploadViaVM PUT error', testUploadViaVMPutError],
['uploadViaVM POST error', testUploadViaVMPostError],
['uploadViaVM progress', testUploadViaVMProgress],
['uploadViaVM проброс session', testUploadViaVMSessionPassed],
];
let failed = 0;
for (const [name, fn] of TESTS) {
try { await fn(); console.log('PASS ' + name); }
catch (e) { failed++; console.error('FAIL ' + name + ': ' + e.message); }
}
if (failed) { console.error(failed + ' тестов упало'); process.exit(1); }
console.log('Все тесты слоя 2 (фронт) прошли');
+23
View File
@@ -0,0 +1,23 @@
// putToVm — PUT одного файла на ВМ-буфер (XHR, сырое тело).
// onProgress(pct) — прогресс загрузки. Разрешается при HTTP 2xx.
export function putToVm(file, url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (options.onXHR) options.onXHR(xhr); // регистрация для отмены (abort)
xhr.open('PUT', url);
xhr.timeout = options.timeoutMs || 300000; // 300с: большие файлы
xhr.upload.onprogress = function(e) {
if (e.lengthComputable && options.onProgress) {
options.onProgress(Math.round(e.loaded / e.total * 100));
}
};
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error('ВМ: HTTP ' + xhr.status));
};
xhr.onerror = function() { reject(new Error('Сеть (ВМ)')); };
xhr.ontimeout = function() { reject(new Error('Таймаут 300с (ВМ)')); };
xhr.send(file); // сырое тело файла
});
}
+59
View File
@@ -0,0 +1,59 @@
// uploadViaVM — слой 2 (фронт): закачать файлы через ВМ-буфер.
// Шаг 1: PUT каждого файла на ВМ (token-ключ в URL). Шаг 2: POST /api/upload_refs.
//
// files: File[] — ТОЛЬКО учитываемые (без сверхлимитных).
// vmUploadUrl: базовый URL ВМ-буфера (напр. "https://.../drhider-upload/").
// options: {
// session, // текущий sid (или '')
// apiBase, // базовый путь бэка, по умолчанию '' (тот же origin)
// onUploadStatus, // (text) — сообщение статуса
// onStatus, // (k, html) — статус в таблице для k-го файла
// }
// Returns: Promise<{ok, session, count} | {ok:false, error}>
import { putToVm } from './put_to_vm.js';
import { fs } from '../table/fs.js';
export async function uploadViaVM(files, vmUploadUrl, options = {}) {
const token = crypto.randomUUID();
const refs = []; // {name, size, url} — для POST /api/upload_refs
const total = files.length;
for (let k = 0; k < total; k++) {
const f = files[k];
const vmUrl = vmUploadUrl + token + '_' + k; // имя файла в URL не несём (токен-ключ)
if (options.onUploadStatus) {
options.onUploadStatus('Загрузка на ВМ (этап 1/2) ' + (k + 1) + '/' + total + ': ' + f.name);
}
try {
const t0 = performance.now();
await putToVm(f, vmUrl, {
onProgress(pct) {
if (options.onStatus) options.onStatus(k, '<span style="color:#2563eb">⏳ ' + pct + '%</span>');
},
onXHR: options.onXHR,
});
const elapsed = (performance.now() - t0) / 1000;
const speed = f.size / elapsed;
if (options.onStatus) options.onStatus(k, '<span style="color:#22c55e">✓ ' + fs(speed) + '/s</span>');
refs.push({ name: f.name, size: f.size, url: vmUrl });
} catch (err) {
if (options.onStatus) options.onStatus(k, '<span style="color:#ef4444">✗</span>');
return { ok: false, error: 'Ошибка загрузки на ВМ: ' + err.message };
}
}
// Шаг 1b: один маленький POST во Flask со ссылками на файлы ВМ (<64КБ)
if (options.onUploadStatus) options.onUploadStatus('Передача ссылок в сервис…');
try {
const resp = await fetch((options.apiBase || '') + '/api/upload_refs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: options.session || '', files: refs })
});
const data = await resp.json();
if (!data.ok) throw new Error(data.error || 'HTTP ' + resp.status);
return { ok: true, session: data.session, count: data.count || refs.length };
} catch (err) {
return { ok: false, error: 'Ошибка передачи ссылок: ' + err.message };
}
}
+22
View File
@@ -0,0 +1,22 @@
// decodeZipName — декодирование имени из ZIP: UTF-8-флаг / эвристика (UTF-8 → CP437 → CP866).
export function decodeZipName(bytes, isUtf8) {
if (isUtf8) return new TextDecoder('utf-8').decode(bytes);
// Многие архиваторы пишут имя в UTF-8, но НЕ выставляют UTF-8-флаг (bit 11).
// Сначала строго пробуем UTF-8: если байты — валидный UTF-8 с кириллицей/текстом,
// берём их как есть (иначе декодирование CP437→CP866 превратит их в «╨╣…»-мусор).
try {
const s = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
// Кириллица — точно UTF-8; либо чистый печатаемый текст без управляющих символов.
if (/[\u0400-\u04FF]/.test(s) || !/[^\u0020-\u007e]/.test(s)) return s;
} catch (e) { /* не UTF-8 — legacy (CP437/CP866) */ }
let name;
try { name = new TextDecoder('ibm437').decode(bytes); }
catch (e) { name = new TextDecoder('utf-8').decode(bytes); }
// Кириллица из 1С (CP866) — перекодировать, если имя пришло как CP437-мусор
if (/[^\x00-\x7f]/.test(name)) {
try { name = new TextDecoder('ibm866').decode(bytes); }
catch (e) { /* оставить как есть */ }
}
return name;
}
+11
View File
@@ -0,0 +1,11 @@
// dosToMs — преобразование DOS-даты/времени (ZIP) в миллисекунды (unix epoch).
export function dosToMs(date, time) {
const year = 1980 + ((date >> 9) & 0x7f);
const month = (date >> 5) & 0x0f;
const day = date & 0x1f;
const hour = (time >> 11) & 0x1f;
const min = (time >> 5) & 0x3f;
const sec = (time & 0x1f) * 2;
return new Date(year, month - 1, day, hour, min, sec).getTime();
}
+8
View File
@@ -0,0 +1,8 @@
// inflateRaw — распаковка deflate-raw (метод 8) через нативный DecompressionStream.
export async function inflateRaw(bytes) {
const ds = new DecompressionStream('deflate-raw');
const stream = new Blob([bytes]).stream().pipeThrough(ds);
const ab = await new Response(stream).arrayBuffer();
return new Uint8Array(ab);
}
+22
View File
@@ -0,0 +1,22 @@
// listZipFiles — рекурсивно достать из ZIP только документы (вложенные zip — разворачиваются).
import { parseZip } from './parse_zip.js';
export async function listZipFiles(file, allowedExt) {
const buf = new Uint8Array(await file.arrayBuffer());
const entries = await parseZip(buf);
const out = [];
// Из ZIP вытаскиваем только документы. Всё прочее (изображения и т.п.) пропускаем.
for (const e of entries) {
if (e.isDir) continue;
const low = e.name.toLowerCase();
if (low.endsWith('.zip')) {
const sub = new File([e.data], e.name, { lastModified: e.dosMs });
out.push(...(await listZipFiles(sub, allowedExt)));
} else if (allowedExt.some(ext => low.endsWith(ext))) {
out.push(new File([e.data], e.name, { lastModified: e.dosMs }));
}
// иначе — не документ, пропускаем
}
return out;
}
+50
View File
@@ -0,0 +1,50 @@
// parseZip — разбор ZIP: центральный каталог → записи {name, data, dosMs, isDir}.
// Поддерживаются методы 0 (store) и 8 (deflate). Без внешних библиотек.
import { decodeZipName } from './decode_zip_name.js';
import { inflateRaw } from './inflate_raw.js';
import { dosToMs } from './dos_to_ms.js';
export async function parseZip(buf) {
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
let eocd = -1;
for (let i = buf.length - 22; i >= 0; i--) {
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new Error('Не ZIP');
const cdSize = dv.getUint32(eocd + 12, true);
const cdOffset = dv.getUint32(eocd + 16, true);
const entries = [];
let pos = cdOffset;
const cdEnd = cdOffset + cdSize;
while (pos < cdEnd) {
if (dv.getUint32(pos, true) !== 0x02014b50) break;
const flags = dv.getUint16(pos + 8, true);
const method = dv.getUint16(pos + 10, true);
const modTime = dv.getUint16(pos + 12, true);
const modDate = dv.getUint16(pos + 14, true);
const compSize = dv.getUint32(pos + 20, true);
const nameLen = dv.getUint16(pos + 28, true);
const extraLen = dv.getUint16(pos + 30, true);
const commentLen = dv.getUint16(pos + 32, true);
const localOffset = dv.getUint32(pos + 42, true);
const nameBytes = buf.slice(pos + 46, pos + 46 + nameLen);
const name = decodeZipName(nameBytes, (flags & 0x800) !== 0);
const lhNameLen = dv.getUint16(localOffset + 26, true);
const lhExtraLen = dv.getUint16(localOffset + 28, true);
const dataStart = localOffset + 30 + lhNameLen + lhExtraLen;
const comp = buf.slice(dataStart, dataStart + compSize);
let data;
if (method === 0) data = comp;
else if (method === 8) data = await inflateRaw(comp);
else throw new Error('Метод сжатия ' + method + ' не поддерживается');
entries.push({ name, data, dosMs: dosToMs(modDate, modTime), isDir: name.endsWith('/') });
pos += 46 + nameLen + extraLen + commentLen;
}
return entries;
}