Задокументированы все 4 попытки, хеш-анализ, находка с Node.js fetch, хронология 12 коммитов за 14.06.2026.
211 lines
10 KiB
Markdown
211 lines
10 KiB
Markdown
# Ответ: архитектура сервиса "Сверка договоров"
|
||
|
||
_Дата: 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`, не фантазировать.
|