Files
contracts/history/sonnet-architecture-answer.md
T
“Naeel” e05af2b6ce doc: сага с LLM — HTTP/2, ddos-guard, httpx→curl
Задокументированы все 4 попытки, хеш-анализ, находка с Node.js fetch,
хронология 12 коммитов за 14.06.2026.
2026-06-14 07:42:54 +04:00

10 KiB
Raw Permalink Blame History

Ответ: архитектура сервиса "Сверка договоров"

Дата: 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 — точка входа и сборки. Бизнес-логики ноль.

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