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
+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`, не фантазировать.