Files
contracts/history/session-01-init.md
T

415 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# История проекта 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