17 KiB
История проекта Contracts (Сверка договоров)
2026-06-13 — Сессия #1: Инициация и развёртывание
Участники
- Сергей Мищук — постановщик задачи
- Владимир Крупский — тех. консультация
- Наиль Тазетдинов — разработка
- GitHub Copilot (DeepSeek V4 Pro) — AI-ассистент
Задача (из fromTelega.md)
Сверка договоров — автоматизированная обработка договоров и допников:
- Разобрать спецификации (docx/pdf) до структурированного вида
- Собрать кумулятивный статус договора по цепочке допников (во времени)
- (опционально) Сопоставить артикулы с каталогом услуг через LLM
Ограничения:
- Данные строго конфиденциальны
- LLM — только своя модель
- Исключения не фантазировать, отмечать как нерешаемые
Решения по технологиям
| Компонент | Выбор | Причина |
|---|---|---|
| Язык | Python 3.12 | python-docx, psycopg2, requests — лучшая экосистема для docx + БД + LLM |
| Фреймворк | Flask | Платформа поддерживает, шаблон Baldurs-Gate-test |
| БД | PostgreSQL | Существующий кластер postgresqlk8s |
| LLM | aillm.ru (120B) | Ключ sk-ucI5YvOticoOQK9ujK5m9Q, резервный DeepSeek Flash |
| Деплой | pythonk8s.services.ngcloud.ru | Платформа Flask-сервисов |
| Репозиторий | gitea.services.ngcloud.ru/Nail/contracts-app.git | Публичный, мастер-ветка |
Отклонённые варианты:
- Node.js — слабый парсинг docx (mammoth → HTML)
- Lucee/CFML — нет инструментов для docx/LLM
- SQLite — не подходит для облачного сервиса
- 3060 (личный сервер) — это личное, не для сервиса
Архитектура
Принцип: НЕ МОНОЛИТИТЬ. Независимые слои = отдельные API.
contracts-app/
├── Dockerfile
├── requirements.txt
├── .env.example
└── site/
├── app.py ← главный, собирает слои (класс ContractsApp)
├── db.py ← слой БД (connect, query)
├── test_routes.py ← слой /test (Blueprint) — мост к БД извне
├── static/css/
└── templates/
Слои (текущие)
| Слой | Файл | Что делает |
|---|---|---|
| Ядро | app.py |
Класс ContractsApp, регистрирует Blueprint'ы |
| БД | db.py |
connect() и query(sql, params) |
| Тест | test_routes.py |
Blueprint /test — статус БД, список таблиц, SQL |
Конфигурация (production)
// startupConfiguration
{ "resourceRealm": "k8s-4-ext-nubes-ru" }
// clusterConfiguration
{ "cpu": "500", "memory": "1024", "replicas": "1" }
// accessConfiguration
{ "domain": "contractor" }
// appConfiguration
{
"gitPath": "https://gitea.services.ngcloud.ru/Nail/contracts-app.git",
"version": "3.12",
"healthPath": "/health"
}
// jsonEnv
{
"DB_HOST": "postgresqlk8s-master.xxx.svc.cluster.local",
"DB_NAME": "contracts",
"DB_PASS": "xnm9KHLibBvT5lYuQzaBVFPYJQHfTxbS4YeEWMcFvtXuXfLaXvgogqJ6uW0nbBUB",
"DB_PORT": "5432",
"DB_USER": "constracts",
"DB_SSLMODE": "disable"
}
⚠️ DB_HOST всё ещё xxx — нужно заменить на реальный:
postgresqlk8s-master.60bdf3e3-5087-41ff-b760-fe6ea544a80e.svc.cluster.local
⚠️ DB_USER написано constracts (опечатка) — должно быть contracts
Хронология деплоев
Попытка #1 — 11:34, Таймаут health check (478 сек)
- Причина:
debug=Trueв app.run(), жёсткие версии в requirements.txt - Решение: убрал debug, смягчил версии, health → plain text
Попытка #2 — 12:12, Под(ы) не работают (ImportError)
- Причина:
from . import db— относительные импорты не работают приpython app.pyиз папки site/ - Ошибка:
ImportError: attempted relative import with no known parent package - Решение: заменил на абсолютные:
import db,from test_routes import test_bp
Попытка #3 — 12:18, Успех ✅
- Health: OK
- DB: fail (переменные не заданы)
Попытка #4 — 12:??, (ожидается)
- Добавлены переменные БД
Платформа: требования Flask-сервиса
Из flask_manual.md:
requirements.txtв корне- Код в
site/ - Запуск:
python app.py - Python: 3.12-slim
- HealthCheck: задаётся параметром
- Git: публичный доступ
API эндпоинты
| Метод | Путь | Что |
|---|---|---|
| GET | / |
Главная (HTML, статус БД) |
| GET | /health |
Health check → "OK" |
| GET | /test |
Статус БД + список команд |
| POST | /test {"action":"tables"} |
Список таблиц |
| POST | /test {"action":"sql","sql":"..."} |
Произвольный SQL |
План дальнейших работ
- ✅ Flask-заготовка деплоится
- ✅ Слои db.py + test_routes
- ✅ Подключить БД (переменные заданы)
- ✅ Создать базу contracts (через /test createdb)
- ✅ Парсер docx/pdf/doc/zip — parser.py
- ⬜ Загрузка документов в БД (contract_docs)
- ⬜ LLM-нормализация строк
- ⬜ Схема таблиц (contracts, spec_rows, spec_history)
- ⬜ DIFF допников, кумулятивная история
- ⬜ Fuzzy-match с каталогом услуг
Хронология деплоев (полная)
Попытка #1 — 11:34, Таймаут health check (478 сек)
- Причина:
debug=Trueв app.run(), жёсткие версии в requirements.txt - Решение: убрал debug, смягчил версии, health → plain text
Попытка #2 — 12:12, Под(ы) не работают (ImportError)
- Причина:
from . import db— относительные импорты не работают приpython app.pyиз папки site/ - Ошибка:
ImportError: attempted relative import with no known parent package - Решение: заменил на абсолютные:
import db,from test_routes import test_bp
Попытка #3 — 12:18, Успех, но БД fail
- Health: OK
- DB: fail — переменные не заданы
Попытка #4 — ~12:31, БД fail — password auth failed
- Хост резолвится (10.102.125.70), порт доступен
- Ошибка:
password authentication failed for user "contracts" - Пароль от ipwhitelist не подходит для пользователя contracts
Попытка #5 — 12:35, db.connect() возвращает ошибки
- Улучшена диагностика — теперь /test показывает точную ошибку
Попытка #6 — ~12:38, БД fail — database "contracts" does not exist
- Аутентификация прошла (пароль исправлен)
- База не существует
Попытка #7 — 12:41, добавлен /test createdb
- Создан endpoint для создания БД через API
Попытка #8 — 12:49, БАЗА СОЗДАНА ✅
POST /test {"action":"createdb"}→DB 'contracts' created/test→db: "ok"- Таблицы: только pg_stat_* (служебные)
Решения по хранению (принято)
- ❌ S3 / приватная репа / файловая система — данные уходят за пределы облака
- ❌ SQLite — не подходит для облачного сервиса
- ✅ PostgreSQL BYTEA + JSONB — всё внутри кластера, без внешних зависимостей
contract_docs— исходные файлы (original_bytes BYTEA) + сырой парсинг (parsed_json JSONB)
Парсер (parser.py)
Принцип: ничего не фильтровать, не терять ни символа.
docx ──▶ python-docx ──▶ elements: [{paragraph, style, text}, {table, rows}, ...]
.doc ──▶ libreoffice ──▶ docx ──▶ python-docx
pdf ──▶ pdfplumber ──▶ страницы → текст + таблицы
zip ──▶ zipfile ──▶ рекурсивно parse() каждый файл
Результат: полный слепок документа → в БД → дальше LLM разбирает.
Протестирован на реальном примере (спецификация-XXX001-03700.docx):
- 12 элементов (4 таблицы + 8 параграфов)
- Все стили сохранены
- Ничего не потеряно
Текущая структура проекта
contracts-app/
├── Dockerfile
├── requirements.txt ← flask, gunicorn, python-docx, requests, psycopg2-binary, python-dotenv, pdfplumber
├── .env.example
├── README.md
└── site/
├── __init__.py
├── app.py ← класс ContractsApp, сборка слоёв
├── db.py ← слой БД: connect(), query(), _pg_connect(), ensure_db()
├── test_routes.py ← слой /test: статус, tables, sql, createdb
├── parser.py ← слой парсера: parse() для docx/pdf/doc/zip
├── static/css/
└── templates/
Конфигурация (актуальная)
// clusterConfiguration
{ "cpu": "500", "memory": "1024", "replicas": "1" }
// accessConfiguration
{ "domain": "contractor" }
// URL: https://contractor.pythonk8s.services.ngcloud.ru
// appConfiguration
{
"gitPath": "https://gitea.services.ngcloud.ru/Nail/contracts-app.git",
"version": "3.12",
"healthPath": "/health"
}
// jsonEnv
{
"DB_HOST": "postgresqlk8s-master.60bdf3e3-5087-41ff-b760-fe6ea544a80e.svc.cluster.local",
"DB_NAME": "contracts",
"DB_PASS": "****",
"DB_PORT": "5432",
"DB_USER": "contracts",
"DB_SSLMODE": "disable",
"LLM_API_KEY": "sk-ucI...",
"LLM_API_URL": "https://api.aillm.ru/v1/chat/completions"
}
Сага с LLM (aillm.ru + HTTP/2)
Проблема
Python-библиотеки (requests, httpx) не держат HTTP/2 из коробки.
api.aillm.ru за ddos-guard, который требует HTTP/2 от облачных IP.
С локальной машины (81.200.23.210) даже HTTP/1.1 работает — для этого IP ddos-guard делает исключение.
Хронология попыток
| # | Подход | Результат |
|---|---|---|
| 1 | requests (HTTP/1.1) |
❌ 401 token_not_found_in_db (ложная ошибка от ddos-guard) |
| 2 | httpx с http2=True |
❌ то же самое — h2 пакет не установился в контейнере |
| 3 | httpx[http2] + h2 в requirements |
❌ h2 не ставится через pip в slim-контейнере |
| 4 | subprocess.run(['curl', '-s', '--http2', ...]) |
⬜ ждёт редеплоя |
Ключевые находки
-
Хеш ключа не совпадал: локально
5fad79ba..., в облакеc772c3e5.... Оказалось — не разные ключи, аddos-guardвозвращает ложный 401 при HTTP/1.1, хешируя какой-то внутренний токен, а не наш ключ. -
Node.js
fetch()работает (проектsay) — потому что undici (нативный fetch в Node) поддерживает HTTP/2 из коробки. Python — нет. -
curl --http2— единственный гарантированный способ получить HTTP/2 в Python-контейнере, без дополнительных пакетов. -
Доступные модели:
gpt-oss-120b,qwen3.6-27b-fp8,qwen3-6-27b-fp8-opt,whisper-large-v3-turbo.
Решение
llm_client.py использует subprocess.run(['curl', '-s', '--http2', ...]) вместо Python HTTP-библиотек.
Хронология коммитов (14.06.2026)
DB слой
ba2e855—db.py:execute()+query_one()для INSERT/UPDATE/DELETE и единичного SELECT4635ed4—test_routes: actionexecчерезdb.execute()(DDL без fetch)9d01dba—schema.py:db.query → db.executeдля DDL
Upload слой
8db35e3—upload.py: Blueprint/upload, поток bytes→parser→textify→DBdb2f6cc— fix MIME: приоритет расширения над content_type
LLM слой
44e53ed—llm_client.py: HTTP-клиент (сначалаrequests)fe8a556—test_routes: actionllmдля тестаc580c93— fix: модельgpt-oss-120b(неdeepseek-chat)5904830— fix:httpx[http2]вместоrequestsc97f26d— fix:h2явно в requirements099edbc— fix:curl --http2черезsubprocessвместо httpx
Debug
cc3203a—test_routes: actiondebugдля просмотра env vars0d0ba18— debug показывает длину ключаb457de4— debug показываетh2_availableиhttpx_version
Развязка саги с LLM: опечатка в ключе
После 2 часов попыток (HTTP/2, h2, curl, httpx) причина оказалась простой:
в переменной LLM_API_KEY в платформе была опечатка.
Сравнение хешей:
- Правильный ключ:
sk-ucI5YvOticoOQ9Kuj5K9mQ→ hash5fad79ba - Ключ в облаке:
sk-ucI5YvOticoOQK9ujK5m9Q→ hashc772c3e5(ошибка 401)
Перепутаны местами 9K → K9 и 5K → K5.
Урок: всегда первым делом проверять входные данные, а не гнаться за архитектурными гипотезами.
Финальное решение LLM
httpx + h2 (отдельно в requirements) + http2=True. Работает.
Модель: gpt-oss-120b (120B, reasoning).
Extractor (extractor.py) — 14.06.2026
LLM-извлечение строк спецификации из распарсенного текста.
Поток: parsed_text → промпт → LLM → JSON → [{row_num, name, price, qty, sum, date}]
Результат на реальном документе: 14 строк из двух таблиц спецификации. LLM корректно определила изменение цены (213 905 → 250 000) и разные даты (01.04 → 25.04).
Промпт: строгий формат JSON, поддержка unresolved для нераспознанных значений.
Differ (differ.py) — 14.06.2026
Сравнение строк спецификаций между допниками. Чистая функция без зависимостей.
Вход: rows_old, rows_new (списки dict)
Выход: {changes: [{row_num, change_type, old_values, new_values, changed_fields}], summary}
change_type: added | deleted | changed | unchanged
Тест: row 1 (changed: price, date), row 2 (unchanged), row 3 (added) ✅
Текущий статус (14.06.2026 08:15)
Готовые слои (11 файлов)
site/
├── app.py — сборка (ensure_schema + Blueprints)
├── db.py — транспорт БД (connect, query, execute, query_one)
├── schema.py — DDL 5 таблиц
├── parser.py — bytes → elements (docx/pdf/doc/zip)
├── textify.py — elements → текст
├── upload.py — POST /upload (file → parse → textify → DB)
├── llm_client.py — HTTP/2 к aillm.ru (httpx+h2)
├── extractor.py — текст → LLM → строки спецификации
├── differ.py — сравнение строк между версиями
├── test_routes.py — /test (status, tables, sql, exec, llm, extract, debug, createdb)
└── templates/
Осталось
api.py— Blueprint для договоров/допников/истории- Интеграция цепочки: upload → extract → save spec_rows → diff → spec_history