v1.0.177: History — объединение history+History, 5 подпапок
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
# История проекта 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
|
||||
Reference in New Issue
Block a user