v1.0.177: History — объединение history+History, 5 подпапок
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
# Архитектура Contracts App
|
||||
|
||||
## Обзор
|
||||
|
||||
Сервис **Сверка договоров** — автоматизированная обработка договоров и допников (docx/pdf)
|
||||
с извлечением структурированных данных, отслеживанием изменений и восстановлением
|
||||
истории договора во времени.
|
||||
|
||||
## Принципы
|
||||
|
||||
1. **НЕ МОНОЛИТ** — каждый слой независим, отдельный файл, своя зона ответственности
|
||||
2. **Данные не покидают облако** — всё в PostgreSQL внутри кластера
|
||||
3. **Ничего не терять** — парсер отдаёт полный слепок документа, LLM решает что важно
|
||||
4. **Исключения — не фантазировать** — нерешаемые подзадачи отмечать явно
|
||||
|
||||
---
|
||||
|
||||
## Слои приложения
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ app.py │
|
||||
│ ContractsApp (сборка) │
|
||||
├──────────┬──────────┬──────────┬────────────┤
|
||||
│ db.py │ parser.py│ test_ │ (будущие) │
|
||||
│ (БД) │ (парсинг)│ routes.py │ llm.py │
|
||||
│ │ │ (/test) │ upload.py│
|
||||
└──────────┴──────────┴──────────┴────────────┘
|
||||
```
|
||||
|
||||
| Слой | Файл | Что делает | Статус |
|
||||
|---|---|---|---|
|
||||
| Ядро | `app.py` | Flask-приложение, инициализация, регистрация Blueprint | ✅ |
|
||||
| БД | `db.py` | `connect()`, `query()`, `_pg_connect()` | ✅ |
|
||||
| Тесты | `test_routes.py` | Blueprint `/test` — мост к БД извне | ✅ |
|
||||
| Парсер | `parser.py` | `parse(bytes, mime) → elements` для docx/pdf/doc/zip | ✅ |
|
||||
| LLM | `llm.py` | Нормализация строк через aillm.ru (120B) | ⬜ |
|
||||
| Загрузка | `upload.py` | Приём файлов, сохранение в БД | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## Поток обработки документа
|
||||
|
||||
```
|
||||
Пользователь
|
||||
│
|
||||
▼
|
||||
POST /upload (файл .docx/.pdf/.doc/.zip)
|
||||
│
|
||||
▼
|
||||
upload.py: сохранить в contract_docs (original_bytes)
|
||||
│
|
||||
▼
|
||||
parser.py: parse(bytes, mime) → elements JSON
|
||||
│
|
||||
▼
|
||||
db.py: сохранить parsed_json в contract_docs
|
||||
│
|
||||
▼
|
||||
llm.py: отправить elements → LLM → нормализованные spec_rows
|
||||
│
|
||||
▼
|
||||
db.py: сохранить в spec_rows, сравнить с предыдущими → spec_history
|
||||
│
|
||||
▼
|
||||
GET /contract/{id}/history → полная история изменений
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Схема БД (план)
|
||||
|
||||
```
|
||||
contract_docs — исходные файлы + сырой парсинг
|
||||
id UUID PK
|
||||
contract_id → contracts.id
|
||||
filename TEXT
|
||||
mime_type TEXT
|
||||
original_bytes BYTEA ← сам файл
|
||||
parsed_json JSONB ← выдача parser.py
|
||||
created_at TIMESTAMPTZ
|
||||
|
||||
contracts — договоры
|
||||
id UUID PK
|
||||
number TEXT ← номер договора
|
||||
client TEXT ← клиент
|
||||
date DATE
|
||||
status TEXT
|
||||
|
||||
supplements — допники
|
||||
id UUID PK
|
||||
contract_id → contracts.id
|
||||
number TEXT
|
||||
date DATE
|
||||
type TEXT ← новый / изменение / расторжение
|
||||
doc_id → contract_docs.id
|
||||
|
||||
spec_rows — строки спецификаций
|
||||
id UUID PK
|
||||
supplement_id → supplements.id
|
||||
row_num INT
|
||||
name TEXT ← наименование услуги
|
||||
price NUMERIC
|
||||
qty NUMERIC
|
||||
sum NUMERIC
|
||||
date_start DATE
|
||||
date_end DATE
|
||||
|
||||
spec_history — история изменений
|
||||
id UUID PK
|
||||
spec_row_id → spec_rows.id
|
||||
supplement_id → supplements.id
|
||||
change_type TEXT ← added / changed / deleted / unchanged
|
||||
old_values JSONB
|
||||
new_values JSONB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API эндпоинты
|
||||
|
||||
| Метод | Путь | Слой | Что |
|
||||
|---|---|---|---|
|
||||
| GET | `/` | app.py | Главная (HTML) |
|
||||
| GET | `/health` | app.py | Health check → "OK" |
|
||||
| GET | `/test` | test_routes | Статус БД + список команд |
|
||||
| POST | `/test` `{"action":"status"}` | test_routes | Статус БД |
|
||||
| POST | `/test` `{"action":"createdb"}` | test_routes | Создать БД contracts |
|
||||
| POST | `/test` `{"action":"tables"}` | test_routes | Список таблиц |
|
||||
| POST | `/test` `{"action":"sql","sql":"..."}` | test_routes | Произвольный SQL |
|
||||
|
||||
---
|
||||
|
||||
## Технологии
|
||||
|
||||
| Компонент | Выбор |
|
||||
|---|---|
|
||||
| Язык | Python 3.12 |
|
||||
| Фреймворк | Flask |
|
||||
| БД | PostgreSQL (внутрикластерный) |
|
||||
| Парсинг docx | python-docx |
|
||||
| Парсинг .doc | LibreOffice (headless) |
|
||||
| Парсинг PDF | pdfplumber |
|
||||
| LLM | aillm.ru API (120B модель) |
|
||||
| Деплой | pythonk8s.services.ngcloud.ru |
|
||||
| Репозиторий | gitea.services.ngcloud.ru/Nail/contracts-app.git |
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация (переменные окружения)
|
||||
|
||||
| Переменная | Назначение |
|
||||
|---|---|
|
||||
| `DB_HOST` | Хост PostgreSQL |
|
||||
| `DB_PORT` | Порт (5432) |
|
||||
| `DB_NAME` | Имя БД (contracts) |
|
||||
| `DB_USER` | Пользователь |
|
||||
| `DB_PASS` | Пароль |
|
||||
| `DB_SSLMODE` | SSL mode (disable) |
|
||||
| `LLM_API_KEY` | Ключ aillm.ru (будет) |
|
||||
| `LLM_API_URL` | URL LLM API (будет) |
|
||||
@@ -0,0 +1,69 @@
|
||||
# Блок-схема сервиса Contracts
|
||||
|
||||
## Полный пайплайн обработки допника
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["📄 POST /api/contracts/{id}/supplements\n(file.docx)"] --> B["💾 documents.original_bytes\n(status=uploaded)"]
|
||||
B --> C["🔧 parser.parse()\nbytes → elements JSON"]
|
||||
C --> D["📝 textify.to_text()\nelements → линейный текст"]
|
||||
D --> E["📄 documents.parsed_text\n(status=parsed)"]
|
||||
E --> F["🤖 extractor.extract()\nтекст → LLM → строки JSON"]
|
||||
F --> G["📊 spec_rows\n(row_num, name, price, qty, sum, date)"]
|
||||
G --> H["🔄 differ.diff()\nсравнение с пред. допником"]
|
||||
H --> I["📋 spec_history\n(added/changed/deleted/unchanged)"]
|
||||
I --> J["✅ ответ API\n{rows, diff_summary}"]
|
||||
```
|
||||
|
||||
## Архитектура слоёв
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "Внешний мир"
|
||||
USER["👤 Пользователь"]
|
||||
end
|
||||
|
||||
subgraph "Flask"
|
||||
APP["app.py\nсборка"]
|
||||
API["api.py\n/contracts"]
|
||||
UPL["upload.py\n/upload"]
|
||||
TEST["test_routes.py\n/test"]
|
||||
end
|
||||
|
||||
subgraph "Бизнес-логика"
|
||||
PARSER["parser.py\nbytes→JSON"]
|
||||
TEXTIFY["textify.py\nJSON→текст"]
|
||||
LLM["llm_client.py\nHTTP/2→LLM"]
|
||||
EXTRACT["extractor.py\nтекст→строки"]
|
||||
DIFF["differ.py\nсравнение"]
|
||||
end
|
||||
|
||||
subgraph "Данные"
|
||||
DB["db.py\nconnect/query"]
|
||||
SCH["schema.py\nDDL"]
|
||||
PG[("PostgreSQL\n5 таблиц")]
|
||||
end
|
||||
|
||||
USER -->|"POST docx"| API
|
||||
USER -->|"GET /test"| TEST
|
||||
API --> PARSER
|
||||
PARSER --> TEXTIFY
|
||||
TEXTIFY --> EXTRACT
|
||||
EXTRACT --> LLM
|
||||
EXTRACT --> DIFF
|
||||
API --> DB
|
||||
DB --> PG
|
||||
APP --> SCH
|
||||
SCH --> DB
|
||||
```
|
||||
|
||||
## Цепочка данных
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["docx\n(байты)"] -->|parser| B["elements\nJSON"]
|
||||
B -->|textify| C["текст\nстрока"]
|
||||
C -->|extractor + LLM| D["spec_rows\nJSON"]
|
||||
D -->|differ| E["changes\nJSON"]
|
||||
E -->|api| F["ответ\nпользователю"]
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
# Пайплайн обработки договора
|
||||
|
||||
## Схема
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 👤 ПОЛЬЗОВАТЕЛЬ │
|
||||
│ Открывает / → выбирает файлы → жмёт «Обработать» │
|
||||
└──────────────────────────┬──────────────────────────────────────────┘
|
||||
│ multipart/form-data (files)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ app.py: _upload_page() POST │
|
||||
│ Принимает файлы, запускает пайплайн │
|
||||
└──────────────────────────┬──────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
file.docx file.pdf file.zip
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ① parser.py │
|
||||
│ docx → python-docx → elements JSON │
|
||||
│ pdf → pdfplumber → elements JSON │
|
||||
│ doc → libreoffice → docx → python-docx │
|
||||
│ zip → zipfile → каждый файл рекурсивно │
|
||||
│ │
|
||||
│ Выход: [{type:"paragraph", style, text}, {type:"table", rows}] │
|
||||
│ НИЧЕГО не фильтрует, не теряет │
|
||||
└──────────────────────────┬──────────────────────────────────────────┘
|
||||
│ elements JSON
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ② textify.py │
|
||||
│ elements → линейный текст │
|
||||
│ │
|
||||
│ [No Spacing] Приложение № 1 │
|
||||
│ [Heading 3] Состав и стоимость Услуг │
|
||||
│ --- Таблица (9×6) --- │
|
||||
│ | № | Наименование | Цена | Объем | Сумма | Дата | │
|
||||
│ | 1 | Аренда стойко-места | 213 905 | 3 | 641 716 | 01.04.2026 | │
|
||||
└──────────────────────────┬──────────────────────────────────────────┘
|
||||
│ текст (строка)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ③ extractor.py + llm_client.py │
|
||||
│ текст → промпт → LLM (gpt-oss-120b) → JSON │
|
||||
│ │
|
||||
│ Промпт: «Найди таблицы услуг, извлеки строки в JSON» │
|
||||
│ Ответ: [{"row_num":1, "name":"Аренда...", "price":213905, │
|
||||
│ "qty":3, "sum":641716, "date_start":"2026-04-01"}, ...] │
|
||||
└──────────────────────────┬──────────────────────────────────────────┘
|
||||
│ строки спецификации
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ④ differ.py │
|
||||
│ Сравнение с предыдущим допником │
|
||||
│ │
|
||||
│ rows_old vs rows_new → changes: │
|
||||
│ added: новая услуга │
|
||||
│ changed: цена 213905 → 250000, дата 01.04 → 25.04 │
|
||||
│ deleted: услуга убрана │
|
||||
│ unchanged: без изменений │
|
||||
└──────────────────────────┬──────────────────────────────────────────┘
|
||||
│ изменения
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ⑤ Сохранение в PostgreSQL │
|
||||
│ │
|
||||
│ documents ← исходный файл + распарсенный текст │
|
||||
│ contracts ← договор │
|
||||
│ supplements ← допник │
|
||||
│ spec_rows ← строки спецификации │
|
||||
│ spec_history ← история изменений │
|
||||
└──────────────────────────┬──────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Ответ пользователю: HTML-страница с таблицей результатов │
|
||||
│ «Извлечено 14 строк, добавлено 14, изменено 0, удалено 0» │
|
||||
│ + полная таблица услуг с ценами │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Кратко
|
||||
|
||||
```
|
||||
файлы → parser → textify → LLM → differ → PostgreSQL → HTML-страница
|
||||
```
|
||||
|
||||
## Слои
|
||||
|
||||
| # | Слой | Что делает |
|
||||
|---|---|---|
|
||||
| ① | `parser.py` | docx/pdf/doc/zip → JSON |
|
||||
| ② | `textify.py` | JSON → текст |
|
||||
| ③ | `extractor.py` + `llm_client.py` | текст → LLM → строки |
|
||||
| ④ | `differ.py` | сравнение с предыдущим |
|
||||
| ⑤ | `db.py` + `schema.py` | сохранение в БД |
|
||||
| — | `app.py` | сборка, приём формы, отдача HTML |
|
||||
@@ -0,0 +1,60 @@
|
||||
# Ответ: HTTP/2 из Python к aillm.ru
|
||||
|
||||
_Дата: 2026-06-14_
|
||||
|
||||
---
|
||||
|
||||
## Проблема
|
||||
`api.aillm.ru` защищен ddos-guard, который требует наличия HTTP/2 в TLS-хендшейке от облачных IP. Библиотека `requests` работает только по HTTP/1.1, что приводит к ложным ошибкам 401/403.
|
||||
|
||||
## Решение
|
||||
Использование библиотеки **`httpx`** с установленной поддержкой HTTP/2 через чистый Python-пакет **`h2`**.
|
||||
|
||||
### 1. Подготовка зависимостей (requirements.txt)
|
||||
Так как облачная платформа не всегда корректно обрабатывает синтаксис `httpx[http2]`, необходимо прописать зависимости **явно отдельными строками**. Это гарантирует установку `h2` — pure Python реализации протокола, которая не требует системных библиотек или `apt`.
|
||||
|
||||
```text
|
||||
# requirements.txt
|
||||
httpx
|
||||
h2
|
||||
```
|
||||
|
||||
### 2. Код клиента (llm_client.py)
|
||||
Для работы по HTTP/2 в `httpx` нужно явно передать параметр `http2=True` при создании клиента.
|
||||
|
||||
```python
|
||||
import os
|
||||
import httpx
|
||||
|
||||
def ask_llm(prompt: str):
|
||||
api_url = "https://api.aillm.ru/v1/chat/completions"
|
||||
api_key = os.getenv("LLM_API_KEY", "sk-ucI5YvOticoOQ9Kuj5K9mQ")
|
||||
|
||||
payload = {
|
||||
"model": "gpt-oss-120b",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 8000,
|
||||
"temperature": 0.1
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# ВАЖНО: http2=True включает поддержку нужного протокола
|
||||
with httpx.Client(http2=True, timeout=120) as client:
|
||||
resp = client.post(api_url, json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
```
|
||||
|
||||
## Почему это работает в slim-контейнере
|
||||
1. **`httpx`** — современный клиент, поддерживающий HTTP/2.
|
||||
2. **`h2`** — это замена системным библиотекам `nghttp2`. Она написана на чистом Python, поэтому корректно устанавливается через `pip` даже в максимально «обрезанных» образах (\*-slim, alpine) без компиляторов и `apt`.
|
||||
3. **HTTP/2 Fingerprint** — ddos-guard видит поддержку HTTP/2 в TLS Client Hello, что является для него признаком «легального» клиента (браузера или современного SDK), а не простого скрипта на `requests` или старого бота.
|
||||
|
||||
## Резюме для реализации
|
||||
- Используйте `httpx.Client(http2=True)`.
|
||||
- Убедитесь, что `h2` есть в `requirements.txt` отдельной строкой.
|
||||
- Не используйте `httpx[http2]` в конфигах, если платформа капризная.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Вопрос: HTTP/2 из Python к aillm.ru
|
||||
|
||||
## Контекст
|
||||
|
||||
Flask-приложение в контейнере `python:3.12-slim`, деплоится через облачную платформу.
|
||||
Нужно отправлять промпты к LLM API: `https://api.aillm.ru/v1/chat/completions`
|
||||
(ключ: `sk-ucI5YvOticoOQ9Kuj5K9mQ`, модель: `gpt-oss-120b`).
|
||||
|
||||
## Проблема
|
||||
|
||||
`api.aillm.ru` за ddos-guard, который **требует HTTP/2 от облачных IP**.
|
||||
С локальной машины работает любой HTTP-клиент (requests, curl без --http2),
|
||||
но из облачного контейнера — только HTTP/2.
|
||||
|
||||
Что пробовали, что НЕ сработало:
|
||||
- `requests` — HTTP/1.1, ddos-guard возвращает ложный 401
|
||||
- `httpx[http2]` в requirements.txt — платформа не парсит `[http2]`, h2 не ставится
|
||||
- `curl` через subprocess — curl не установлен в slim-контейнере, apt недоступен
|
||||
- свой HTTP/2 клиент на h2+socket — слишком сложно, заказчик против
|
||||
|
||||
## Вопрос
|
||||
|
||||
**Как в Python из slim-контейнера сделать HTTP/2 POST-запрос к aillm.ru?**
|
||||
|
||||
Требования:
|
||||
1. Никакого apt/curl — только pip
|
||||
2. Просто, без велосипедов на сокетах
|
||||
3. Работает в python:3.12-slim
|
||||
4. Ключ передаётся как `Authorization: Bearer sk-ucI5YvOticoOQ9Kuj5K9mQ`
|
||||
5. Тело: JSON с model, messages, max_tokens, temperature
|
||||
|
||||
Доступные модели на aillm.ru: `gpt-oss-120b`, `qwen3.6-27b-fp8`, `qwen3-6-27b-fp8-opt`, `whisper-large-v3-turbo`.
|
||||
|
||||
## Для справки
|
||||
|
||||
В Node.js это работает одной строкой:
|
||||
```js
|
||||
await fetch('https://api.aillm.ru/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'gpt-oss-120b', messages: [...] })
|
||||
});
|
||||
```
|
||||
|
||||
Нужен такой же простой способ в Python.
|
||||
@@ -0,0 +1,145 @@
|
||||
Read [](file:///home/naeel/nubes/contracts/contractor/apply_events.cfm)
|
||||
|
||||
Read [](file:///home/naeel/nubes/contracts/contractor/db.cfc#1-1), lines 1 to 150
|
||||
|
||||
Read [](file:///home/naeel/nubes/contracts/contractor/deploy/llm_prompt.py)
|
||||
|
||||
Read [](file:///home/naeel/nubes/contracts/contractor/deploy/convert_server.py#1-1), lines 1 to 150
|
||||
|
||||
Read [](file:///home/naeel/nubes/contracts/contractor/deploy/convert_server.py#150-150), lines 150 to 250
|
||||
|
||||
Проанализировал все файлы. Вот подробный аудит.
|
||||
|
||||
---
|
||||
|
||||
## Аудит Event Sourcing — сверка договоров
|
||||
|
||||
---
|
||||
|
||||
### Находки Gemini — подтверждение/опровержение
|
||||
|
||||
**1. Race condition `MAX(seq)` — ✅ ПОДТВЕРЖДЕНО, критично**
|
||||
|
||||
apply_events.cfm: `SELECT COALESCE(MAX(seq), 0)` → `seq++` в цикле — это read-modify-write без блокировки. Два параллельных вызова (разные supplements) получат одинаковый `MAX(seq)` и сгенерируют одинаковые seq. В DDL есть `UNIQUE(contract_id, seq)` — транзакция упадёт с ошибкой дублирующегося ключа.
|
||||
|
||||
**Рекомендация:** Заменить на `nextval` через sequence (`CREATE SEQUENCE spec_events_seq`) или PostgreSQL `INSERT ... RETURNING` с `seq = (SELECT COALESCE(MAX(seq),0)+1 FROM spec_events WHERE contract_id=... FOR UPDATE)` — с `FOR UPDATE` для пессимистической блокировки.
|
||||
|
||||
---
|
||||
|
||||
**2. `full_replace` с пустым `ops` стирает спецификацию — ✅ ПОДТВЕРЖДЕНО, критично**
|
||||
|
||||
apply_events.cfm: Если LLM вернул `mode=full_replace` с пустым `ops[]` (например, не распознал таблицу), код сначала DELETE всех строк из `spec_current`, потом цикл по ops не выполняется. Спецификация обнулена без восстановления.
|
||||
|
||||
**Рекомендация:** Добавить guard: если `mode == full_replace` и `len(ops) == 0` — отклонить с ошибкой, не трогать `spec_current`.
|
||||
|
||||
---
|
||||
|
||||
**3. LLM возвращает строку вместо числа → `cf_sql_float` падает — ✅ ПОДТВЕРЖДЕНО**
|
||||
|
||||
apply_events.cfm: Переменные `pr`, `qt`, `sm` берутся напрямую из `op.new_row`. Промпт в llm_prompt.py говорит «ЧИСЛА, не строки», но LLM может вернуть `"1 000,00"` или `"null"` как строку. `cf_sql_float` с нечисловой строкой кидает исключение внутри транзакции — вся транзакция откатывается.
|
||||
|
||||
**Рекомендация:** Перед передачей в cfqueryparam привести к числу через `val()` или попытаться `javacast("double", pr)` с catch.
|
||||
|
||||
---
|
||||
|
||||
**4. N+1 SELECT в цикле UPDATE — ✅ ПОДТВЕРЖДЕНО**
|
||||
|
||||
apply_events.cfm: Для каждого UPDATE-op выполняется отдельный `SELECT price, qty, sum, date_start FROM spec_current WHERE name_hash=?`. Если ДС обновляет 50 строк — 50 SELECT-запросов внутри одной транзакции.
|
||||
|
||||
**Рекомендация:** Перед циклом собрать все `target_hash` UPDATE-ops, сделать один `SELECT ... WHERE name_hash = ANY(ARRAY[...])`, сложить результат в struct по hash.
|
||||
|
||||
---
|
||||
|
||||
**5. Построчный INSERT в `full_replace` — ✅ ПОДТВЕРЖДЕНО**
|
||||
|
||||
apply_events.cfm: В `full_replace`-блоке цикл по `curRows` выполняет по одному `INSERT INTO spec_events` на каждую строку. После — снова цикл по `ops`, каждый ADD — ещё 2 INSERT. Для 100-строчной спецификации: 100+100×2 = 300 отдельных INSERT внутри одной транзакции.
|
||||
|
||||
**Рекомендация:** Использовать `INSERT INTO spec_events ... SELECT unnest(...)` или сформировать multi-row VALUES через ColdFusion loop перед запросом.
|
||||
|
||||
---
|
||||
|
||||
**6. `name_hash` зависит от LLM-форматирования — ✅ ПОДТВЕРЖДЕНО, фундаментальная проблема**
|
||||
|
||||
apply_events.cfm: `md5(lower(trim(name)) || coalesce(date_start, ''))` вычисляется на стороне PostgreSQL из данных, которые LLM только что вернул. В llm_prompt.py: в `_build_diff` LLM видит `[hash: {hash}]` и должен вернуть этот же hash в `target_hash`. Но LLM может сократить название, изменить регистр или пробелы → `md5` будет другим → UPDATE-op не найдёт строку → `cfthrow "UPDATE target not found"`.
|
||||
|
||||
**Рекомендация:** Это структурная проблема. Варианты:
|
||||
- Передавать LLM только `hash` как непрозрачный идентификатор (уже так и делается), но добавить fuzzy-matching на стороне apply: если `target_hash` не найден — искать по близкому имени и создавать UNRESOLVED вместо throw.
|
||||
- Добавить отдельный endpoint для ручного разрешения UNRESOLVED.
|
||||
|
||||
---
|
||||
|
||||
**7. `last_event_id` UUID → `cf_sql_varchar` — ✅ ПОДТВЕРЖДЕНО**
|
||||
|
||||
apply_events.cfm: `<cfqueryparam value="#evt.id#" cfsqltype="cf_sql_varchar">` для колонки `last_event_id UUID`. Lucee отправит строку, PostgreSQL неявно приведёт к UUID — это работает, но неправильно. При включённом строгом режиме или нестандартном JDBC-драйвере может дать ошибку. Стоит использовать `cfsqltype="cf_sql_char"` с `maxlength="36"` или более явный тип.
|
||||
|
||||
---
|
||||
|
||||
### Упущенные проблемы
|
||||
|
||||
**8. SQL-инъекция в `_lucee_query` — КРИТИЧНО**
|
||||
|
||||
convert_server.py: Метод `_lucee_query` принимает готовую SQL-строку, которую формируют через f-string интерполяцию. Например:
|
||||
```python
|
||||
f"... WHERE s.contract_id='{cid}' ..."
|
||||
```
|
||||
`cid` приходит из GET-параметра `?contract_id=...` без какой-либо санитизации. Если атакующий передаст `cid = "'; DROP TABLE spec_current; --"` — запрос выполнится.
|
||||
|
||||
**Рекомендация:** Передавать `contract_id` как параметр через `params` API Lucee, а не конкатенацией в строку. Либо хотя бы проверять `cid` по regex UUID: `re.fullmatch(r'[0-9a-f-]{36}', cid)`.
|
||||
|
||||
---
|
||||
|
||||
**9. `elements_json` double-decode — баг надёжности**
|
||||
|
||||
convert_server.py:
|
||||
```python
|
||||
ej = docs[0]["elements_json"]
|
||||
if isinstance(ej, dict) and "Value" in ej:
|
||||
ej = ej["Value"]
|
||||
elements = json.loads(ej)
|
||||
```
|
||||
Если Lucee вернул `elements_json` уже как распарсенный dict (без `"Value"` обёртки), то `json.loads(dict)` упадёт с `TypeError`. Условие `isinstance(ej, dict)` обрабатывает только Lucee-обёртку, но если `ej` уже список — код падает.
|
||||
|
||||
**Рекомендация:** `if isinstance(ej, (dict, list)): elements = ej if isinstance(ej, list) else json.loads(ej["Value"])`.
|
||||
|
||||
---
|
||||
|
||||
**10. `full_replace` DELETE не в правильном порядке в аудит-логе**
|
||||
|
||||
apply_events.cfm: DELETE-события для old строк записываются с `seq++` начиная с 0, затем ADD-события из `ops` продолжают seq. Это корректно. НО: если replay этих событий — сначала DELETE всего, потом ADD новых. Проблема: `UNIQUE(contract_id, seq)` не гарантирует порядок применения при replay, если seq не строго монотонный из-за прошлых partial-операций. Нет механизма replay/восстановления из событий.
|
||||
|
||||
**Рекомендация:** Либо добавить флаг `is_replayable` в архитектурный документ (явно задокументировать что replay не поддерживается), либо добавить `applied_at TIMESTAMPTZ` и `parent_seq` для правильного replay.
|
||||
|
||||
---
|
||||
|
||||
**11. LLM-ключ в открытом виде в коде**
|
||||
|
||||
convert_server.py: `LLM_KEY = "sk-ucI5YvOticoOQ9Kuj5K9mQ"` — hardcoded в файле. При любом git push ключ попадёт в историю.
|
||||
|
||||
**Рекомендация:** Перенести в переменную окружения `os.environ.get("LLM_KEY")`.
|
||||
|
||||
---
|
||||
|
||||
**12. Нет идемпотентности — повторный вызов дублирует данные**
|
||||
|
||||
Нет проверки что `supplement_id` уже обработан. Если `/process-v2` вызвать дважды для одного контракта — все operations применятся повторно: ADD-строки задублируются (или упадут на `UNIQUE name_hash`), seq увеличится.
|
||||
|
||||
**Рекомендация:** Добавить `status` колонку в `supplements` (`processed` / `pending`) и проверять в начале цикла. Либо проверять `SELECT COUNT(*) FROM spec_events WHERE supplement_id = ?` перед обработкой.
|
||||
|
||||
---
|
||||
|
||||
### Итоговая таблица приоритетов
|
||||
|
||||
| # | Проблема | Критичность |
|
||||
|---|----------|-------------|
|
||||
| 8 | SQL-инъекция в `_lucee_query` | 🔴 Критично |
|
||||
| 1 | Race condition MAX(seq) | 🔴 Критично |
|
||||
| 2 | full_replace + empty ops → потеря данных | 🔴 Критично |
|
||||
| 12 | Нет идемпотентности | 🟠 Высокая |
|
||||
| 6 | name_hash нестабилен | 🟠 Высокая |
|
||||
| 3 | LLM строка вместо числа → crash | 🟠 Высокая |
|
||||
| 11 | LLM-ключ в коде | 🟡 Средняя |
|
||||
| 9 | elements_json double-decode | 🟡 Средняя |
|
||||
| 4 | N+1 SELECT в UPDATE | 🟡 Средняя |
|
||||
| 5 | Построчный INSERT в full_replace | 🟢 Низкая |
|
||||
| 7 | UUID как cf_sql_varchar | 🟢 Низкая |
|
||||
| 10 | Нет механизма replay | 🟢 Низкая |
|
||||
@@ -0,0 +1,210 @@
|
||||
# Ответ: архитектура сервиса "Сверка договоров"
|
||||
|
||||
_Дата: 2026-06-13_
|
||||
|
||||
---
|
||||
|
||||
## 1. Правильно ли разбиты слои?
|
||||
|
||||
Разбивка в целом правильная. Принцип «один файл — одна ответственность» выдержан.
|
||||
Критических проблем нет, но есть два момента, которые стоит учесть.
|
||||
|
||||
### Что хорошо
|
||||
- `parser.py` — чисто I/O-слой: байты → JSON. Никакой логики.
|
||||
- `textify.py` — чисто форматирование: JSON → текст. Никакой логики.
|
||||
- `db.py` — чисто транспорт к БД. Не знает о бизнес-сущностях.
|
||||
- `test_routes.py` — отдельный Blueprint, не засоряет app.py.
|
||||
|
||||
### Что стоит скорректировать
|
||||
|
||||
**`db.py` — разделить на транспорт и схему.**
|
||||
Сейчас там `ensure_db()` — это уже «знание» о схеме. Когда появятся таблицы
|
||||
(`contracts`, `supplements`, `spec_rows`, `spec_history`), их создание (DDL)
|
||||
стоит вынести в отдельный `schema.py`. `db.py` остаётся просто `connect()` и `query()`.
|
||||
|
||||
**Слой LLM стоит разделить надвое:**
|
||||
- `llm_client.py` — HTTP-клиент к aillm.ru: отправить промпт → получить строку ответа.
|
||||
Не знает ни о договорах, ни о спецификациях.
|
||||
- `extractor.py` — бизнес-логика: взять текст договора, сформировать промпт,
|
||||
вызвать llm_client, распарсить ответ в строки спецификации.
|
||||
|
||||
Это важно: если поменяется LLM — меняем только `llm_client.py`.
|
||||
Если поменяется формат ответа — только `extractor.py`.
|
||||
|
||||
**Итоговый состав слоёв:**
|
||||
|
||||
```
|
||||
parser.py bytes → elements JSON (уже есть, не трогать)
|
||||
textify.py elements → текст для LLM (уже есть, не трогать)
|
||||
db.py connect() + query() (уже есть, убрать ensure_db)
|
||||
schema.py DDL: CREATE TABLE IF NOT EXISTS
|
||||
upload.py сохранить файл в БД (documents)
|
||||
llm_client.py HTTP к aillm.ru → строка ответа
|
||||
extractor.py текст → структурированные строки (промпт + парсинг ответа)
|
||||
differ.py сравнение строк между допниками → список изменений
|
||||
api.py Blueprint: /contracts, /supplements, /history
|
||||
app.py сборка слоёв, Flask-приложение
|
||||
test_routes.py Blueprint /test (уже есть)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. В каком порядке создавать
|
||||
|
||||
Каждый этап самодостаточен и проверяем до перехода к следующему.
|
||||
|
||||
### Этап 1 — основа хранения
|
||||
`schema.py` → DDL всех таблиц.
|
||||
Запустить `python schema.py` — таблицы созданы. Проверить через `/test sql`.
|
||||
|
||||
### Этап 2 — загрузка файлов
|
||||
`upload.py` → принять файл, вызвать `parser.parse()`, вызвать `textify.to_text()`,
|
||||
сохранить в `documents(original_bytes, parsed_text, mime, filename)`.
|
||||
Добавить POST `/upload` в `app.py`. Проверить curl-ом.
|
||||
|
||||
### Этап 3 — LLM клиент
|
||||
`llm_client.py` → POST к aillm.ru, вернуть строку.
|
||||
Проверить отдельно: `python llm_client.py` с тестовым промптом.
|
||||
|
||||
### Этап 4 — извлечение строк спецификации
|
||||
`extractor.py` → взять `parsed_text`, сформировать промпт, вызвать `llm_client`,
|
||||
распарсить ответ в список `spec_row`.
|
||||
Проверить на одном docx через тест-скрипт.
|
||||
|
||||
### Этап 5 — сравнение (diff)
|
||||
`differ.py` → взять два списка `spec_row`, вернуть изменения.
|
||||
Это чистая функция: `diff(rows_old, rows_new) → changes`.
|
||||
Проверить unit-тестом без БД.
|
||||
|
||||
### Этап 6 — API
|
||||
`api.py` → Blueprint с GET/POST для договоров, допников, истории.
|
||||
Подключить в `app.py`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Поток данных между слоями
|
||||
|
||||
Правило: слои передают данные через простые Python-структуры (dict, list).
|
||||
Никаких прямых вызовов «через слой» — только соседние слои.
|
||||
|
||||
```
|
||||
Файл (bytes)
|
||||
│
|
||||
▼
|
||||
parser.parse(bytes, mime) → {"elements": [...]}
|
||||
│
|
||||
▼
|
||||
textify.to_text(elements) → str
|
||||
│
|
||||
▼
|
||||
upload.py: сохранить в DB, получить document_id
|
||||
│
|
||||
▼
|
||||
extractor.extract(parsed_text) → [{"pos": 1, "name": "...", "qty": 10, ...}]
|
||||
│ (внутри вызывает llm_client.ask(prompt) → str)
|
||||
│
|
||||
▼
|
||||
schema: сохранить строки в spec_rows(document_id, pos, ...)
|
||||
│
|
||||
▼
|
||||
differ.diff(rows_v1, rows_v2) → [{"pos": 3, "field": "qty", "old": 5, "new": 10}]
|
||||
│
|
||||
▼
|
||||
schema: сохранить в spec_history
|
||||
```
|
||||
|
||||
Между слоями **нет импортов друг друга**, кроме:
|
||||
- `upload.py` импортирует `parser` и `textify` (это нормально — upload оркеструет парсинг)
|
||||
- `extractor.py` импортирует `llm_client` (клиент — зависимость экстрактора)
|
||||
- `app.py` и `api.py` импортируют всё — они и есть точки сборки
|
||||
|
||||
`db.py` никто не импортирует напрямую, кроме `upload.py`, `extractor.py` и `api.py`.
|
||||
`schema.py` вызывается только один раз при старте из `app.py`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Как должен выглядеть app.py
|
||||
|
||||
`app.py` — точка входа и сборки. Бизнес-логики ноль.
|
||||
|
||||
```python
|
||||
from flask import Flask
|
||||
import db, schema
|
||||
from test_routes import test_bp
|
||||
from api import api_bp
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
# 1. Инициализация схемы при старте
|
||||
schema.ensure_schema()
|
||||
|
||||
# 2. Регистрация Blueprint-ов
|
||||
app.register_blueprint(test_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
|
||||
# 3. Системные маршруты
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return "OK", 200
|
||||
|
||||
return app
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_app().run(host="0.0.0.0", port=5000)
|
||||
```
|
||||
|
||||
Правило: если в `app.py` появляется `if`, `for` или бизнес-слово — это уже лишнее.
|
||||
|
||||
---
|
||||
|
||||
## 5. Потенциальные проблемы
|
||||
|
||||
### LLM не гарантирует структуру ответа
|
||||
Самая острая проблема. Модель может вернуть текст в произвольном формате,
|
||||
сломать JSON, пропустить поля, придумать данные.
|
||||
|
||||
**Решение:**
|
||||
- В `extractor.py` — строгая схема промпта с примером ответа.
|
||||
- Парсинг ответа через `try/except` с явным возвратом `{"error": "parse_failed", "raw": ответ}`.
|
||||
- Никогда не падать — помечать строки как `unresolved`.
|
||||
|
||||
### Идентификация изменённой строки в допнике
|
||||
Самая неоднозначная задача: иногда в допнике новое полное состояние,
|
||||
иногда — дельта. Определить это автоматически сложно.
|
||||
|
||||
**Решение для `differ.py`:**
|
||||
- Сначала попробовать детерминированный diff по позиции/артикулу.
|
||||
- Если совпадение < порога — пометить как `ambiguous`, не фантазировать.
|
||||
- Заказчик потом разбирает вручную `ambiguous`-записи.
|
||||
|
||||
### Сопоставление артикула с каталогом
|
||||
Задача нетривиальная, код-имён в спецификациях нет, матч только по описанию.
|
||||
|
||||
**Решение:** вынести в отдельный `matcher.py`, реализовать как отдельный шаг после основного пайплайна. Пометить как `optional`, не блокировать основной поток.
|
||||
|
||||
### Размер документов vs контекст LLM
|
||||
Большая спецификация (100+ строк) может не влезть в контекст.
|
||||
|
||||
**Решение в `extractor.py`:** разбивать таблицы на чанки, обрабатывать частями,
|
||||
собирать результат. Это нужно заложить сразу — переделывать потом дороже.
|
||||
|
||||
### Транзакционность при загрузке
|
||||
Файл загружен → парсинг ок → LLM вызов → ошибка → документ в БД наполовину.
|
||||
|
||||
**Решение:** хранить в `documents` поле `status` (`uploaded` / `parsed` / `extracted` / `error`).
|
||||
Обновлять после каждого шага. Зависший `uploaded` — сигнал для повтора.
|
||||
|
||||
---
|
||||
|
||||
## Итог
|
||||
|
||||
| Вопрос | Ответ |
|
||||
|--------|-------|
|
||||
| Разбивка слоёв | Правильная. Добавить `schema.py`, разделить LLM на `llm_client` + `extractor` |
|
||||
| Порядок | schema → upload → llm_client → extractor → differ → api |
|
||||
| Поток данных | Через dict/list, нет перекрёстных импортов |
|
||||
| app.py | Только сборка: `ensure_schema()` + `register_blueprint()` |
|
||||
| Риски | LLM-нестабильность, diff-амбивалентность, чанкинг, транзакционность |
|
||||
|
||||
Всё, что не решается надёжно детерминированно — помечать `unresolved`, не фантазировать.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Запрос к Claude Sonnet: архитектура сервиса "Сверка договоров"
|
||||
|
||||
## Контекст
|
||||
|
||||
Мы строим Flask-сервис для автоматизированной обработки договоров и допников.
|
||||
|
||||
Исходная постановка задачи (от заказчика):
|
||||
1. Спецификации договоров разобрать до структурированного вида
|
||||
2. Собрать из **цепочки допников кумулятивный статус договора** — состояние во времени.
|
||||
Важно: иногда в допнике **новое состояние** договора целиком, иногда — **только изменения**,
|
||||
и нужно идентифицировать изменённую строку.
|
||||
3. (опционально, малый процент случаев) сопоставить артикул по описанию с каталогом услуг
|
||||
(кодов в спецификациях нет)
|
||||
|
||||
Критичные ограничения:
|
||||
- Данные строго конфиденциальны → LLM только своя (aillm.ru 120B), данные не покидают облако
|
||||
- На каждом этапе возможны исключения — **не фантазировать**, а отметить конкретную
|
||||
элементарную подзадачу как нерешаемую
|
||||
- Рассматриваем как задачку для ИИ
|
||||
|
||||
## Ключевое требование заказчика
|
||||
|
||||
**НЕ МОНОЛИТИТЬ.** Всё делать небольшими НЕЗАВИСИМЫМИ слоями.
|
||||
Каждый слой — отдельный Python-файл, своя зона ответственности.
|
||||
Слои не должны зависеть друг от друга (или минимально).
|
||||
|
||||
## Что уже сделано
|
||||
|
||||
```
|
||||
site/
|
||||
├── app.py ← Flask-приложение, класс ContractsApp, только сборка слоёв
|
||||
├── db.py ← слой БД: connect(), query(), _pg_connect()
|
||||
├── test_routes.py ← Blueprint /test (статус БД, список таблиц, SQL, createdb)
|
||||
├── parser.py ← слой парсера: parse(bytes, mime) → полный слепок docx/pdf
|
||||
├── textify.py ← слой: elements JSON → линейный текст для LLM
|
||||
├── static/
|
||||
└── templates/
|
||||
```
|
||||
|
||||
База: PostgreSQL, всё внутри облачного кластера.
|
||||
LLM: aillm.ru (120B модель).
|
||||
|
||||
## Что предстоит сделать
|
||||
|
||||
1. **Слой загрузки** — приём файлов через API, сохранение в БД (original_bytes + parsed_text)
|
||||
2. **Слой LLM** — отправка текста → модель → структурированные строки спецификации
|
||||
3. **Слой хранения** — таблицы contracts, supplements, spec_rows, spec_history
|
||||
4. **Слой сравнения (diff)** — сравнение строк между допниками, выявление изменений
|
||||
5. **Слой API** — отдача истории договора, списка, etc.
|
||||
|
||||
## Вопрос
|
||||
|
||||
Разъясни план архитектуры:
|
||||
|
||||
1. Правильно ли разбиты слои? Может что-то объединить или наоборот — разделить?
|
||||
2. В каком порядке их создавать?
|
||||
3. Как организовать поток данных между слоями, чтобы они оставались независимыми?
|
||||
4. Как должен выглядеть основной оркестратор (app.py) — без бизнес-логики?
|
||||
5. Какие потенциальные проблемы ты видишь с таким подходом?
|
||||
|
||||
## Текущий код для ознакомления
|
||||
|
||||
### parser.py (bytes → JSON elements)
|
||||
Использует python-docx для docx, libreoffice для .doc, pdfplumber для PDF, zipfile для zip.
|
||||
Возвращает полный слепок: все абзацы (со стилями) + все таблицы (все строки).
|
||||
Ничего не фильтрует, не теряет.
|
||||
|
||||
### textify.py (JSON elements → текст)
|
||||
Форматирует elements в линейный текст: параграфы как `[Style] text`, таблицы как `| cell | cell |`.
|
||||
Тоже не фильтрует, только форматирует.
|
||||
|
||||
### db.py (БД)
|
||||
connect() в целевую БД, _pg_connect(dbname) в любую, query(sql) — выполнить произвольный запрос.
|
||||
|
||||
### test_routes.py (/test Blueprint)
|
||||
GET /test — статус БД, POST /test с действиями: status, tables, sql, createdb.
|
||||
Мост к БД извне для отладки.
|
||||
|
||||
## Ожидаемый формат ответа
|
||||
|
||||
Структурированный план архитектуры с пояснениями по каждому пункту вопроса.
|
||||
@@ -0,0 +1,414 @@
|
||||
# История проекта Contracts (Сверка договоров)
|
||||
|
||||
## 2026-06-13 — Сессия #1: Инициация и развёртывание
|
||||
|
||||
### Участники
|
||||
- Сергей Мищук — постановщик задачи
|
||||
- Владимир Крупский — тех. консультация
|
||||
- Наиль Тазетдинов — разработка
|
||||
- GitHub Copilot (DeepSeek V4 Pro) — AI-ассистент
|
||||
|
||||
---
|
||||
|
||||
## Задача (из fromTelega.md)
|
||||
|
||||
**Сверка договоров** — автоматизированная обработка договоров и допников:
|
||||
|
||||
1. Разобрать спецификации (docx/pdf) до структурированного вида
|
||||
2. Собрать кумулятивный статус договора по цепочке допников (во времени)
|
||||
3. (опционально) Сопоставить артикулы с каталогом услуг через LLM
|
||||
|
||||
**Ограничения:**
|
||||
- Данные строго конфиденциальны
|
||||
- LLM — только своя модель
|
||||
- Исключения не фантазировать, отмечать как нерешаемые
|
||||
|
||||
---
|
||||
|
||||
## Решения по технологиям
|
||||
|
||||
| Компонент | Выбор | Причина |
|
||||
|---|---|---|
|
||||
| Язык | Python 3.12 | python-docx, psycopg2, requests — лучшая экосистема для docx + БД + LLM |
|
||||
| Фреймворк | Flask | Платформа поддерживает, шаблон Baldurs-Gate-test |
|
||||
| БД | PostgreSQL | Существующий кластер postgresqlk8s |
|
||||
| LLM | aillm.ru (120B) | Ключ sk-ucI5YvOticoOQK9ujK5m9Q, резервный DeepSeek Flash |
|
||||
| Деплой | pythonk8s.services.ngcloud.ru | Платформа Flask-сервисов |
|
||||
| Репозиторий | gitea.services.ngcloud.ru/Nail/contracts-app.git | Публичный, мастер-ветка |
|
||||
|
||||
**Отклонённые варианты:**
|
||||
- Node.js — слабый парсинг docx (mammoth → HTML)
|
||||
- Lucee/CFML — нет инструментов для docx/LLM
|
||||
- SQLite — не подходит для облачного сервиса
|
||||
- 3060 (личный сервер) — это личное, не для сервиса
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
**Принцип: НЕ МОНОЛИТИТЬ. Независимые слои = отдельные API.**
|
||||
|
||||
```
|
||||
contracts-app/
|
||||
├── Dockerfile
|
||||
├── requirements.txt
|
||||
├── .env.example
|
||||
└── site/
|
||||
├── app.py ← главный, собирает слои (класс ContractsApp)
|
||||
├── db.py ← слой БД (connect, query)
|
||||
├── test_routes.py ← слой /test (Blueprint) — мост к БД извне
|
||||
├── static/css/
|
||||
└── templates/
|
||||
```
|
||||
|
||||
### Слои (текущие)
|
||||
|
||||
| Слой | Файл | Что делает |
|
||||
|---|---|---|
|
||||
| Ядро | `app.py` | Класс ContractsApp, регистрирует Blueprint'ы |
|
||||
| БД | `db.py` | `connect()` и `query(sql, params)` |
|
||||
| Тест | `test_routes.py` | Blueprint `/test` — статус БД, список таблиц, SQL |
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация (production)
|
||||
|
||||
```json
|
||||
// startupConfiguration
|
||||
{ "resourceRealm": "k8s-4-ext-nubes-ru" }
|
||||
|
||||
// clusterConfiguration
|
||||
{ "cpu": "500", "memory": "1024", "replicas": "1" }
|
||||
|
||||
// accessConfiguration
|
||||
{ "domain": "contractor" }
|
||||
|
||||
// appConfiguration
|
||||
{
|
||||
"gitPath": "https://gitea.services.ngcloud.ru/Nail/contracts-app.git",
|
||||
"version": "3.12",
|
||||
"healthPath": "/health"
|
||||
}
|
||||
|
||||
// jsonEnv
|
||||
{
|
||||
"DB_HOST": "postgresqlk8s-master.xxx.svc.cluster.local",
|
||||
"DB_NAME": "contracts",
|
||||
"DB_PASS": "xnm9KHLibBvT5lYuQzaBVFPYJQHfTxbS4YeEWMcFvtXuXfLaXvgogqJ6uW0nbBUB",
|
||||
"DB_PORT": "5432",
|
||||
"DB_USER": "constracts",
|
||||
"DB_SSLMODE": "disable"
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ **DB_HOST** всё ещё `xxx` — нужно заменить на реальный:
|
||||
`postgresqlk8s-master.60bdf3e3-5087-41ff-b760-fe6ea544a80e.svc.cluster.local`
|
||||
|
||||
⚠️ **DB_USER** написано `constracts` (опечатка) — должно быть `contracts`
|
||||
|
||||
---
|
||||
|
||||
## Хронология деплоев
|
||||
|
||||
### Попытка #1 — 11:34, Таймаут health check (478 сек)
|
||||
- **Причина:** `debug=True` в app.run(), жёсткие версии в requirements.txt
|
||||
- **Решение:** убрал debug, смягчил версии, health → plain text
|
||||
|
||||
### Попытка #2 — 12:12, Под(ы) не работают (ImportError)
|
||||
- **Причина:** `from . import db` — относительные импорты не работают при `python app.py` из папки site/
|
||||
- **Ошибка:** `ImportError: attempted relative import with no known parent package`
|
||||
- **Решение:** заменил на абсолютные: `import db`, `from test_routes import test_bp`
|
||||
|
||||
### Попытка #3 — 12:18, Успех ✅
|
||||
- Health: OK
|
||||
- DB: fail (переменные не заданы)
|
||||
|
||||
### Попытка #4 — 12:??, (ожидается)
|
||||
- Добавлены переменные БД
|
||||
|
||||
---
|
||||
|
||||
## Платформа: требования Flask-сервиса
|
||||
|
||||
Из `flask_manual.md`:
|
||||
- `requirements.txt` в корне
|
||||
- Код в `site/`
|
||||
- Запуск: `python app.py`
|
||||
- Python: 3.12-slim
|
||||
- HealthCheck: задаётся параметром
|
||||
- Git: публичный доступ
|
||||
|
||||
---
|
||||
|
||||
## API эндпоинты
|
||||
|
||||
| Метод | Путь | Что |
|
||||
|---|---|---|
|
||||
| GET | `/` | Главная (HTML, статус БД) |
|
||||
| GET | `/health` | Health check → "OK" |
|
||||
| GET | `/test` | Статус БД + список команд |
|
||||
| POST | `/test` `{"action":"tables"}` | Список таблиц |
|
||||
| POST | `/test` `{"action":"sql","sql":"..."}` | Произвольный SQL |
|
||||
|
||||
---
|
||||
|
||||
## План дальнейших работ
|
||||
|
||||
1. ✅ Flask-заготовка деплоится
|
||||
2. ✅ Слои db.py + test_routes
|
||||
3. ✅ Подключить БД (переменные заданы)
|
||||
4. ✅ Создать базу contracts (через /test createdb)
|
||||
5. ✅ Парсер docx/pdf/doc/zip — parser.py
|
||||
6. ⬜ Загрузка документов в БД (contract_docs)
|
||||
7. ⬜ LLM-нормализация строк
|
||||
8. ⬜ Схема таблиц (contracts, spec_rows, spec_history)
|
||||
9. ⬜ DIFF допников, кумулятивная история
|
||||
10. ⬜ Fuzzy-match с каталогом услуг
|
||||
|
||||
---
|
||||
|
||||
## Хронология деплоев (полная)
|
||||
|
||||
### Попытка #1 — 11:34, Таймаут health check (478 сек)
|
||||
- **Причина:** `debug=True` в app.run(), жёсткие версии в requirements.txt
|
||||
- **Решение:** убрал debug, смягчил версии, health → plain text
|
||||
|
||||
### Попытка #2 — 12:12, Под(ы) не работают (ImportError)
|
||||
- **Причина:** `from . import db` — относительные импорты не работают при `python app.py` из папки site/
|
||||
- **Ошибка:** `ImportError: attempted relative import with no known parent package`
|
||||
- **Решение:** заменил на абсолютные: `import db`, `from test_routes import test_bp`
|
||||
|
||||
### Попытка #3 — 12:18, Успех, но БД fail
|
||||
- Health: OK
|
||||
- DB: fail — переменные не заданы
|
||||
|
||||
### Попытка #4 — ~12:31, БД fail — password auth failed
|
||||
- Хост резолвится (10.102.125.70), порт доступен
|
||||
- Ошибка: `password authentication failed for user "contracts"`
|
||||
- Пароль от ipwhitelist не подходит для пользователя contracts
|
||||
|
||||
### Попытка #5 — 12:35, db.connect() возвращает ошибки
|
||||
- Улучшена диагностика — теперь /test показывает точную ошибку
|
||||
|
||||
### Попытка #6 — ~12:38, БД fail — database "contracts" does not exist
|
||||
- Аутентификация прошла (пароль исправлен)
|
||||
- База не существует
|
||||
|
||||
### Попытка #7 — 12:41, добавлен /test createdb
|
||||
- Создан endpoint для создания БД через API
|
||||
|
||||
### Попытка #8 — 12:49, БАЗА СОЗДАНА ✅
|
||||
- `POST /test {"action":"createdb"}` → `DB 'contracts' created`
|
||||
- `/test` → `db: "ok"`
|
||||
- Таблицы: только pg_stat_* (служебные)
|
||||
|
||||
---
|
||||
|
||||
## Решения по хранению (принято)
|
||||
|
||||
- ❌ S3 / приватная репа / файловая система — данные уходят за пределы облака
|
||||
- ❌ SQLite — не подходит для облачного сервиса
|
||||
- ✅ **PostgreSQL BYTEA + JSONB** — всё внутри кластера, без внешних зависимостей
|
||||
- `contract_docs` — исходные файлы (original_bytes BYTEA) + сырой парсинг (parsed_json JSONB)
|
||||
|
||||
---
|
||||
|
||||
## Парсер (parser.py)
|
||||
|
||||
Принцип: **ничего не фильтровать, не терять ни символа**.
|
||||
|
||||
```
|
||||
docx ──▶ python-docx ──▶ elements: [{paragraph, style, text}, {table, rows}, ...]
|
||||
.doc ──▶ libreoffice ──▶ docx ──▶ python-docx
|
||||
pdf ──▶ pdfplumber ──▶ страницы → текст + таблицы
|
||||
zip ──▶ zipfile ──▶ рекурсивно parse() каждый файл
|
||||
```
|
||||
|
||||
Результат: полный слепок документа → в БД → дальше LLM разбирает.
|
||||
|
||||
Протестирован на реальном примере (спецификация-XXX001-03700.docx):
|
||||
- 12 элементов (4 таблицы + 8 параграфов)
|
||||
- Все стили сохранены
|
||||
- Ничего не потеряно
|
||||
|
||||
---
|
||||
|
||||
## Текущая структура проекта
|
||||
|
||||
```
|
||||
contracts-app/
|
||||
├── Dockerfile
|
||||
├── requirements.txt ← flask, gunicorn, python-docx, requests, psycopg2-binary, python-dotenv, pdfplumber
|
||||
├── .env.example
|
||||
├── README.md
|
||||
└── site/
|
||||
├── __init__.py
|
||||
├── app.py ← класс ContractsApp, сборка слоёв
|
||||
├── db.py ← слой БД: connect(), query(), _pg_connect(), ensure_db()
|
||||
├── test_routes.py ← слой /test: статус, tables, sql, createdb
|
||||
├── parser.py ← слой парсера: parse() для docx/pdf/doc/zip
|
||||
├── static/css/
|
||||
└── templates/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация (актуальная)
|
||||
|
||||
```json
|
||||
// clusterConfiguration
|
||||
{ "cpu": "500", "memory": "1024", "replicas": "1" }
|
||||
|
||||
// accessConfiguration
|
||||
{ "domain": "contractor" }
|
||||
// URL: https://contractor.pythonk8s.services.ngcloud.ru
|
||||
|
||||
// appConfiguration
|
||||
{
|
||||
"gitPath": "https://gitea.services.ngcloud.ru/Nail/contracts-app.git",
|
||||
"version": "3.12",
|
||||
"healthPath": "/health"
|
||||
}
|
||||
|
||||
// jsonEnv
|
||||
{
|
||||
"DB_HOST": "postgresqlk8s-master.60bdf3e3-5087-41ff-b760-fe6ea544a80e.svc.cluster.local",
|
||||
"DB_NAME": "contracts",
|
||||
"DB_PASS": "****",
|
||||
"DB_PORT": "5432",
|
||||
"DB_USER": "contracts",
|
||||
"DB_SSLMODE": "disable",
|
||||
"LLM_API_KEY": "sk-ucI...",
|
||||
"LLM_API_URL": "https://api.aillm.ru/v1/chat/completions"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Сага с LLM (aillm.ru + HTTP/2)
|
||||
|
||||
### Проблема
|
||||
Python-библиотеки (`requests`, `httpx`) не держат HTTP/2 из коробки.
|
||||
`api.aillm.ru` за ddos-guard, который **требует HTTP/2 от облачных IP**.
|
||||
С локальной машины (81.200.23.210) даже HTTP/1.1 работает — для этого IP ddos-guard делает исключение.
|
||||
|
||||
### Хронология попыток
|
||||
|
||||
| # | Подход | Результат |
|
||||
|---|---|---|
|
||||
| 1 | `requests` (HTTP/1.1) | ❌ 401 token_not_found_in_db (ложная ошибка от ddos-guard) |
|
||||
| 2 | `httpx` с `http2=True` | ❌ то же самое — `h2` пакет не установился в контейнере |
|
||||
| 3 | `httpx[http2]` + `h2` в requirements | ❌ `h2` не ставится через pip в slim-контейнере |
|
||||
| 4 | `subprocess.run(['curl', '-s', '--http2', ...])` | ⬜ ждёт редеплоя |
|
||||
|
||||
### Ключевые находки
|
||||
|
||||
1. **Хеш ключа не совпадал:** локально `5fad79ba...`, в облаке `c772c3e5...`.
|
||||
Оказалось — не разные ключи, а `ddos-guard` возвращает ложный 401 при HTTP/1.1,
|
||||
хешируя какой-то внутренний токен, а не наш ключ.
|
||||
|
||||
2. **Node.js `fetch()` работает** (проект `say`) — потому что undici (нативный fetch в Node)
|
||||
поддерживает HTTP/2 из коробки. Python — нет.
|
||||
|
||||
3. **`curl --http2`** — единственный гарантированный способ получить HTTP/2 в Python-контейнере,
|
||||
без дополнительных пакетов.
|
||||
|
||||
4. **Доступные модели:** `gpt-oss-120b`, `qwen3.6-27b-fp8`, `qwen3-6-27b-fp8-opt`, `whisper-large-v3-turbo`.
|
||||
|
||||
### Решение
|
||||
`llm_client.py` использует `subprocess.run(['curl', '-s', '--http2', ...])` вместо Python HTTP-библиотек.
|
||||
|
||||
---
|
||||
|
||||
## Хронология коммитов (14.06.2026)
|
||||
|
||||
### DB слой
|
||||
- `ba2e855` — `db.py`: `execute()` + `query_one()` для INSERT/UPDATE/DELETE и единичного SELECT
|
||||
- `4635ed4` — `test_routes`: action `exec` через `db.execute()` (DDL без fetch)
|
||||
- `9d01dba` — `schema.py`: `db.query → db.execute` для DDL
|
||||
|
||||
### Upload слой
|
||||
- `8db35e3` — `upload.py`: Blueprint `/upload`, поток bytes→parser→textify→DB
|
||||
- `db2f6cc` — fix MIME: приоритет расширения над content_type
|
||||
|
||||
### LLM слой
|
||||
- `44e53ed` — `llm_client.py`: HTTP-клиент (сначала `requests`)
|
||||
- `fe8a556` — `test_routes`: action `llm` для теста
|
||||
- `c580c93` — fix: модель `gpt-oss-120b` (не `deepseek-chat`)
|
||||
- `5904830` — fix: `httpx[http2]` вместо `requests`
|
||||
- `c97f26d` — fix: `h2` явно в requirements
|
||||
- `099edbc` — fix: `curl --http2` через `subprocess` вместо httpx
|
||||
|
||||
### Debug
|
||||
- `cc3203a` — `test_routes`: action `debug` для просмотра env vars
|
||||
- `0d0ba18` — debug показывает длину ключа
|
||||
- `b457de4` — debug показывает `h2_available` и `httpx_version`
|
||||
|
||||
---
|
||||
|
||||
## Развязка саги с LLM: опечатка в ключе
|
||||
|
||||
После 2 часов попыток (HTTP/2, h2, curl, httpx) причина оказалась простой:
|
||||
**в переменной `LLM_API_KEY` в платформе была опечатка**.
|
||||
|
||||
Сравнение хешей:
|
||||
- Правильный ключ: `sk-ucI5YvOticoOQ9Kuj5K9mQ` → hash `5fad79ba`
|
||||
- Ключ в облаке: `sk-ucI5YvOticoOQK9ujK5m9Q` → hash `c772c3e5` (ошибка 401)
|
||||
|
||||
Перепутаны местами `9K` → `K9` и `5K` → `K5`.
|
||||
|
||||
**Урок:** всегда первым делом проверять входные данные, а не гнаться за архитектурными гипотезами.
|
||||
|
||||
### Финальное решение LLM
|
||||
`httpx` + `h2` (отдельно в requirements) + `http2=True`. Работает.
|
||||
Модель: `gpt-oss-120b` (120B, reasoning).
|
||||
|
||||
---
|
||||
|
||||
## Extractor (extractor.py) — 14.06.2026
|
||||
|
||||
LLM-извлечение строк спецификации из распарсенного текста.
|
||||
|
||||
**Поток:** `parsed_text → промпт → LLM → JSON → [{row_num, name, price, qty, sum, date}]`
|
||||
|
||||
**Результат на реальном документе:** 14 строк из двух таблиц спецификации.
|
||||
LLM корректно определила изменение цены (213 905 → 250 000) и разные даты (01.04 → 25.04).
|
||||
|
||||
Промпт: строгий формат JSON, поддержка `unresolved` для нераспознанных значений.
|
||||
|
||||
---
|
||||
|
||||
## Differ (differ.py) — 14.06.2026
|
||||
|
||||
Сравнение строк спецификаций между допниками. Чистая функция без зависимостей.
|
||||
|
||||
**Вход:** `rows_old`, `rows_new` (списки dict)
|
||||
**Выход:** `{changes: [{row_num, change_type, old_values, new_values, changed_fields}], summary}`
|
||||
|
||||
**change_type:** `added` | `deleted` | `changed` | `unchanged`
|
||||
|
||||
**Тест:** row 1 (changed: price, date), row 2 (unchanged), row 3 (added) ✅
|
||||
|
||||
---
|
||||
|
||||
## Текущий статус (14.06.2026 08:15)
|
||||
|
||||
### Готовые слои (11 файлов)
|
||||
```
|
||||
site/
|
||||
├── app.py — сборка (ensure_schema + Blueprints)
|
||||
├── db.py — транспорт БД (connect, query, execute, query_one)
|
||||
├── schema.py — DDL 5 таблиц
|
||||
├── parser.py — bytes → elements (docx/pdf/doc/zip)
|
||||
├── textify.py — elements → текст
|
||||
├── upload.py — POST /upload (file → parse → textify → DB)
|
||||
├── llm_client.py — HTTP/2 к aillm.ru (httpx+h2)
|
||||
├── extractor.py — текст → LLM → строки спецификации
|
||||
├── differ.py — сравнение строк между версиями
|
||||
├── test_routes.py — /test (status, tables, sql, exec, llm, extract, debug, createdb)
|
||||
└── templates/
|
||||
```
|
||||
|
||||
### Осталось
|
||||
- `api.py` — Blueprint для договоров/допников/истории
|
||||
- Интеграция цепочки: upload → extract → save spec_rows → diff → spec_history
|
||||
@@ -0,0 +1,49 @@
|
||||
# Проблемы проекта Contracts App (15.06.2026)
|
||||
|
||||
_Дата: 15.06.2026_
|
||||
_Статус: Анализ и выученные уроки_
|
||||
|
||||
---
|
||||
|
||||
## 1. Технические ошибки и инструменты
|
||||
|
||||
### ⛔ replace_string_in_file портит файлы
|
||||
Инструмент заменяет подстроку, а не файл целиком. При повторных правках возникали дубликаты кода (например, два блока `if __name__ == "__main__"`) или обрезка функций.
|
||||
**Решение:** При больших правках использовать `create_file` для перезаписи файла целиком, либо очень внимательно выбирать контекст в `oldString`.
|
||||
|
||||
### ⛔ Опечатка в API-ключе (c772c3e5 vs 5fad79ba)
|
||||
Два часа отладки HTTP/2 и `httpx` из-за опечатки в `LLM_API_KEY` (перепутаны символы `9K` -> `K9`).
|
||||
**Урок:** Проверять валидность ключей и выводить первые/последние символы хеша в логи при ошибках 401.
|
||||
|
||||
### ⛔ Истёкший SSL-сертификат api.aillm.ru
|
||||
Сертификат протух 14 июня 2026. `httpx` блокировал запросы.
|
||||
**Временное решение:** `verify=False` в клиенте LLM.
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектура и Сеть
|
||||
|
||||
### ⛔ ERR_HTTP2_PROTOCOL_ERROR (Таймауты Ingress)
|
||||
LLM обрабатывает документ 30-40 секунд. Ingress в K8s обрывает соединение на 30-й секунде.
|
||||
**Решение:** Разделение процессов. Загрузка — мгновенно. Обработка LLM — по отдельному запросу с фронтенда с визуальным таймером, чтобы пользователь понимал, что процесс идет.
|
||||
|
||||
### ⛔ AJAX fetch() -> Failed to fetch
|
||||
Сложная логика на фронте (очереди, JSZip) ломалась.
|
||||
**Решение:** Упрощение до стандартных форм. Паттерн **PRG (Post-Redirect-Get)** для устранения проблемы «Подтвердите повторную отправку формы» при нажатии F5.
|
||||
|
||||
---
|
||||
|
||||
## 3. Фронтенд и Контент
|
||||
|
||||
### ⛔ Битый логотип
|
||||
`nubes-logo.svg` содержал текст "Forbidden" (ошибка копирования через `curl`).
|
||||
**Урок:** Проверять содержимое статических файлов после скачивания.
|
||||
|
||||
---
|
||||
|
||||
## Текущее состояние (v1.0)
|
||||
|
||||
1. **Упрощение:** Форма `<input type="file" multiple>` + стандартный `submit`.
|
||||
2. **Надежность:** POST на `/` загружает и парсит файлы, затем делает `redirect` на страницу договора.
|
||||
3. **LLM:** Отдельная кнопка «Обработать (LLM)» внутри договора, чтобы избежать таймаутов при массовой загрузке.
|
||||
4. **Интерфейс:** Добавлен таймер обработки и версия **v1.0** в шапке.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Анализ ошибки ERR_HTTP2_PROTOCOL_ERROR (15.06.2026)
|
||||
|
||||
## Проблема
|
||||
При загрузке нескольких или тяжелых файлов (PDF/DOCX) через форму на главной странице, браузер через 30 секунд выдает ошибку `ERR_HTTP2_PROTOCOL_ERROR`.
|
||||
|
||||
## Причина
|
||||
В текущей реализации `app.py` функция `_upload_files` выполняет **синхронный парсинг** каждого файла в цикле:
|
||||
1. Сохранение байтов в БД.
|
||||
2. `parser_mod.parse(file_bytes, mime)` — тяжелая операция (особенно PDF через `pdfplumber` или DOC через `libreoffice`).
|
||||
3. Формирование текста через `textify`.
|
||||
4. Обновление БД.
|
||||
|
||||
Если суммарное время парсинга всех файлов превышает 30 секунд, K8s Ingress обрывает соединение, не дождавшись HTTP-ответа (Redirect) от Flask.
|
||||
|
||||
## План действий
|
||||
1. **Разделить загрузку и парсинг**:
|
||||
* В `_upload_files` (POST /) только сохранять файлы в БД со статусом `uploaded`.
|
||||
* Сразу делать редирект на страницу договора.
|
||||
2. **Перенести парсинг в процесс обработки**:
|
||||
* В `_process` (POST /process/<cid>) добавить шаг: если документ еще не распарсен (`status='uploaded'`), сначала вызвать `parser` и `textify`.
|
||||
3. **Визуализация**:
|
||||
* Пользователь на странице договора увидит кнопку «Обработать» с таймером. Теперь и парсинг, и LLM будут работать под этим таймером, не блокируя загрузку.
|
||||
|
||||
## Статус
|
||||
- [x] Оптимизировать `app.py` (убрать парсинг из загрузки).
|
||||
- [x] Обновить `_process` (добавить ленивый парсинг).
|
||||
- [x] Добавить таймер на загрузку.
|
||||
- [x] **РЕШЕНО**: Перевод обработки в Background Thread (threading) для обхода таймаутов Ingress.
|
||||
- [x] **ФИКС ZIP**: Добавлена логика обхода вложенных файлов в `_run_processing_task` (раньше ZIP возвращал пустой текст).
|
||||
|
||||
## План "Асинхронное спасение" (15.06.2026 16:00)
|
||||
1. **Threading**: В `app.py` запуск обработки в фоновом потоке, чтобы сразу вернуть ответ 200 OK.
|
||||
2. **Polling**: Фронтенд опрашивает статус обработки.
|
||||
3. **No More POST**: Замена классической формы на `fetch()`, чтобы убрать окно "Подтвердите отправку" при F5.
|
||||
|
||||
## Дополнение от 15:40
|
||||
Проблема `ERR_HTTP2_PROTOCOL_ERROR` на загрузке (даже без парсинга) указывает на то, что сама передача байтов в БД через `db.execute` для нескольких файлов занимает более 30 секунд.
|
||||
Решение:
|
||||
1. Визуальный таймер поможет понять реальное время «зависания».
|
||||
2. Если ошибка повторится — это жесткий лимит Ingress на размер тела запроса (Client Max Body Size) или таймаут записи в сетевую БД.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Исправление PRG-паттерна и окна повторной отправки (15.06.2026)
|
||||
|
||||
## Проблема
|
||||
При нажатии F5 (перезагрузка) браузер выдает окно «Подтвердите повторную отправку формы». Это происходит потому, что последним запросом был POST (на загрузку или на обработку), и страница отображается как результат этого POST-запроса.
|
||||
|
||||
## Решение: PRG (Post-Redirect-Get)
|
||||
Все POST-обработчики должны завершаться инструкцией `redirect(...)`.
|
||||
1. Пользователь отправляет POST.
|
||||
2. Сервер обрабатывает данные.
|
||||
3. Сервер возвращает статус 302 (Redirect).
|
||||
4. Браузер делает автоматический GET на новый URL.
|
||||
5. Теперь в истории браузера последний запрос — GET. При нажатии F5 просто обновится страница без всяких окон.
|
||||
|
||||
## Что сделано:
|
||||
- В `app.py` проверено, что `_upload_files` делает `redirect("/?id=" + contract_id)`.
|
||||
- В `app.py` проверено, что `_process` делает `redirect("/?id=" + cid)`.
|
||||
- Добавлена очистка параметров, если они могут вызвать зацикливание.
|
||||
|
||||
## Статус
|
||||
- [x] Реализован Redirect после всех POST-запросов.
|
||||
- [x] Окно подтверждения больше не должно появляться при F5 на странице договора.
|
||||
Reference in New Issue
Block a user