# Ответ: архитектура сервиса "Сверка договоров" _Дата: 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`, не фантазировать.