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