doc: сага с LLM — HTTP/2, ddos-guard, httpx→curl
Задокументированы все 4 попытки, хеш-анализ, находка с Node.js fetch, хронология 12 коммитов за 14.06.2026.
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
Мост к БД извне для отладки.
|
||||
|
||||
## Ожидаемый формат ответа
|
||||
|
||||
Структурированный план архитектуры с пояснениями по каждому пункту вопроса.
|
||||
Reference in New Issue
Block a user