Files
contracts/History/sessions/session-01-init.md
T

17 KiB
Raw Blame History

История проекта 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)

// 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
  • /testdb: "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', ...]) ждёт редеплоя

Ключевые находки

  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 слой

  • ba2e855db.py: execute() + query_one() для INSERT/UPDATE/DELETE и единичного SELECT
  • 4635ed4test_routes: action exec через db.execute() (DDL без fetch)
  • 9d01dbaschema.py: db.query → db.execute для DDL

Upload слой

  • 8db35e3upload.py: Blueprint /upload, поток bytes→parser→textify→DB
  • db2f6cc — fix MIME: приоритет расширения над content_type

LLM слой

  • 44e53edllm_client.py: HTTP-клиент (сначала requests)
  • fe8a556test_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

  • cc3203atest_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)

Перепутаны местами 9KK9 и 5KK5.

Урок: всегда первым делом проверять входные данные, а не гнаться за архитектурными гипотезами.

Финальное решение 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