# История проекта Contracts (Сверка договоров) ## 2026-06-13 — Сессия #1: Инициация и развёртывание ### Участники - Сергей Мищук — постановщик задачи - Владимир Крупский — тех. консультация - Наиль Тазетдинов — разработка - GitHub Copilot (DeepSeek V4 Pro) — AI-ассистент --- ## Задача (из fromTelega.md) **Сверка договоров** — автоматизированная обработка договоров и допников: 1. Разобрать спецификации (docx/pdf) до структурированного вида 2. Собрать кумулятивный статус договора по цепочке допников (во времени) 3. (опционально) Сопоставить артикулы с каталогом услуг через 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) ```json // 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 | --- ## План дальнейших работ 1. ✅ Flask-заготовка деплоится 2. ✅ Слои db.py + test_routes 3. ✅ Подключить БД (переменные заданы) 4. ✅ Создать базу contracts (через /test createdb) 5. ✅ Парсер docx/pdf/doc/zip — parser.py 6. ⬜ Загрузка документов в БД (contract_docs) 7. ⬜ LLM-нормализация строк 8. ⬜ Схема таблиц (contracts, spec_rows, spec_history) 9. ⬜ DIFF допников, кумулятивная история 10. ⬜ 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/ ``` --- ## Конфигурация (актуальная) ```json // 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', ...])` | ⬜ ждёт редеплоя | ### Ключевые находки 1. **Хеш ключа не совпадал:** локально `5fad79ba...`, в облаке `c772c3e5...`. Оказалось — не разные ключи, а `ddos-guard` возвращает ложный 401 при HTTP/1.1, хешируя какой-то внутренний токен, а не наш ключ. 2. **Node.js `fetch()` работает** (проект `say`) — потому что undici (нативный fetch в Node) поддерживает HTTP/2 из коробки. Python — нет. 3. **`curl --http2`** — единственный гарантированный способ получить HTTP/2 в Python-контейнере, без дополнительных пакетов. 4. **Доступные модели:** `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 и единичного SELECT - `4635ed4` — `test_routes`: action `exec` через `db.execute()` (DDL без fetch) - `9d01dba` — `schema.py`: `db.query → db.execute` для DDL ### Upload слой - `8db35e3` — `upload.py`: Blueprint `/upload`, поток bytes→parser→textify→DB - `db2f6cc` — fix MIME: приоритет расширения над content_type ### LLM слой - `44e53ed` — `llm_client.py`: HTTP-клиент (сначала `requests`) - `fe8a556` — `test_routes`: action `llm` для теста - `c580c93` — fix: модель `gpt-oss-120b` (не `deepseek-chat`) - `5904830` — fix: `httpx[http2]` вместо `requests` - `c97f26d` — fix: `h2` явно в requirements - `099edbc` — fix: `curl --http2` через `subprocess` вместо httpx ### Debug - `cc3203a` — `test_routes`: action `debug` для просмотра env vars - `0d0ba18` — debug показывает длину ключа - `b457de4` — debug показывает `h2_available` и `httpx_version` --- ## Развязка саги с LLM: опечатка в ключе После 2 часов попыток (HTTP/2, h2, curl, httpx) причина оказалась простой: **в переменной `LLM_API_KEY` в платформе была опечатка**. Сравнение хешей: - Правильный ключ: `sk-ucI5YvOticoOQ9Kuj5K9mQ` → hash `5fad79ba` - Ключ в облаке: `sk-ucI5YvOticoOQK9ujK5m9Q` → hash `c772c3e5` (ошибка 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