doc: сага с LLM — HTTP/2, ddos-guard, httpx→curl

Задокументированы все 4 попытки, хеш-анализ, находка с Node.js fetch,
хронология 12 коммитов за 14.06.2026.
This commit is contained in:
“Naeel”
2026-06-14 07:42:54 +04:00
parent 879e016754
commit e05af2b6ce
3 changed files with 355 additions and 1 deletions
+64 -1
View File
@@ -277,6 +277,69 @@ contracts-app/
"DB_PASS": "****",
"DB_PORT": "5432",
"DB_USER": "contracts",
"DB_SSLMODE": "disable"
"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`
+210
View File
@@ -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`, не фантазировать.
+81
View File
@@ -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.
Мост к БД извне для отладки.
## Ожидаемый формат ответа
Структурированный план архитектуры с пояснениями по каждому пункту вопроса.