v1.0.177: History — объединение history+History, 5 подпапок

This commit is contained in:
“Naeel”
2026-06-25 07:53:56 +04:00
parent 14cca9f8cf
commit c40d8eb5a8
54 changed files with 0 additions and 0 deletions
+414
View File
@@ -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
+49
View File
@@ -0,0 +1,49 @@
# Проблемы проекта Contracts App (15.06.2026)
_Дата: 15.06.2026_
_Статус: Анализ и выученные уроки_
---
## 1. Технические ошибки и инструменты
### ⛔ replace_string_in_file портит файлы
Инструмент заменяет подстроку, а не файл целиком. При повторных правках возникали дубликаты кода (например, два блока `if __name__ == "__main__"`) или обрезка функций.
**Решение:** При больших правках использовать `create_file` для перезаписи файла целиком, либо очень внимательно выбирать контекст в `oldString`.
### ⛔ Опечатка в API-ключе (c772c3e5 vs 5fad79ba)
Два часа отладки HTTP/2 и `httpx` из-за опечатки в `LLM_API_KEY` (перепутаны символы `9K` -> `K9`).
**Урок:** Проверять валидность ключей и выводить первые/последние символы хеша в логи при ошибках 401.
### ⛔ Истёкший SSL-сертификат api.aillm.ru
Сертификат протух 14 июня 2026. `httpx` блокировал запросы.
**Временное решение:** `verify=False` в клиенте LLM.
---
## 2. Архитектура и Сеть
### ⛔ ERR_HTTP2_PROTOCOL_ERROR (Таймауты Ingress)
LLM обрабатывает документ 30-40 секунд. Ingress в K8s обрывает соединение на 30-й секунде.
**Решение:** Разделение процессов. Загрузка — мгновенно. Обработка LLM — по отдельному запросу с фронтенда с визуальным таймером, чтобы пользователь понимал, что процесс идет.
### ⛔ AJAX fetch() -> Failed to fetch
Сложная логика на фронте (очереди, JSZip) ломалась.
**Решение:** Упрощение до стандартных форм. Паттерн **PRG (Post-Redirect-Get)** для устранения проблемы «Подтвердите повторную отправку формы» при нажатии F5.
---
## 3. Фронтенд и Контент
### ⛔ Битый логотип
`nubes-logo.svg` содержал текст "Forbidden" (ошибка копирования через `curl`).
**Урок:** Проверять содержимое статических файлов после скачивания.
---
## Текущее состояние (v1.0)
1. **Упрощение:** Форма `<input type="file" multiple>` + стандартный `submit`.
2. **Надежность:** POST на `/` загружает и парсит файлы, затем делает `redirect` на страницу договора.
3. **LLM:** Отдельная кнопка «Обработать (LLM)» внутри договора, чтобы избежать таймаутов при массовой загрузке.
4. **Интерфейс:** Добавлен таймер обработки и версия **v1.0** в шапке.
+40
View File
@@ -0,0 +1,40 @@
# Анализ ошибки ERR_HTTP2_PROTOCOL_ERROR (15.06.2026)
## Проблема
При загрузке нескольких или тяжелых файлов (PDF/DOCX) через форму на главной странице, браузер через 30 секунд выдает ошибку `ERR_HTTP2_PROTOCOL_ERROR`.
## Причина
В текущей реализации `app.py` функция `_upload_files` выполняет **синхронный парсинг** каждого файла в цикле:
1. Сохранение байтов в БД.
2. `parser_mod.parse(file_bytes, mime)` — тяжелая операция (особенно PDF через `pdfplumber` или DOC через `libreoffice`).
3. Формирование текста через `textify`.
4. Обновление БД.
Если суммарное время парсинга всех файлов превышает 30 секунд, K8s Ingress обрывает соединение, не дождавшись HTTP-ответа (Redirect) от Flask.
## План действий
1. **Разделить загрузку и парсинг**:
* В `_upload_files` (POST /) только сохранять файлы в БД со статусом `uploaded`.
* Сразу делать редирект на страницу договора.
2. **Перенести парсинг в процесс обработки**:
* В `_process` (POST /process/<cid>) добавить шаг: если документ еще не распарсен (`status='uploaded'`), сначала вызвать `parser` и `textify`.
3. **Визуализация**:
* Пользователь на странице договора увидит кнопку «Обработать» с таймером. Теперь и парсинг, и LLM будут работать под этим таймером, не блокируя загрузку.
## Статус
- [x] Оптимизировать `app.py` (убрать парсинг из загрузки).
- [x] Обновить `_process` (добавить ленивый парсинг).
- [x] Добавить таймер на загрузку.
- [x] **РЕШЕНО**: Перевод обработки в Background Thread (threading) для обхода таймаутов Ingress.
- [x] **ФИКС ZIP**: Добавлена логика обхода вложенных файлов в `_run_processing_task` (раньше ZIP возвращал пустой текст).
## План "Асинхронное спасение" (15.06.2026 16:00)
1. **Threading**: В `app.py` запуск обработки в фоновом потоке, чтобы сразу вернуть ответ 200 OK.
2. **Polling**: Фронтенд опрашивает статус обработки.
3. **No More POST**: Замена классической формы на `fetch()`, чтобы убрать окно "Подтвердите отправку" при F5.
## Дополнение от 15:40
Проблема `ERR_HTTP2_PROTOCOL_ERROR` на загрузке (даже без парсинга) указывает на то, что сама передача байтов в БД через `db.execute` для нескольких файлов занимает более 30 секунд.
Решение:
1. Визуальный таймер поможет понять реальное время «зависания».
2. Если ошибка повторится — это жесткий лимит Ingress на размер тела запроса (Client Max Body Size) или таймаут записи в сетевую БД.
+21
View File
@@ -0,0 +1,21 @@
# Исправление PRG-паттерна и окна повторной отправки (15.06.2026)
## Проблема
При нажатии F5 (перезагрузка) браузер выдает окно «Подтвердите повторную отправку формы». Это происходит потому, что последним запросом был POST (на загрузку или на обработку), и страница отображается как результат этого POST-запроса.
## Решение: PRG (Post-Redirect-Get)
Все POST-обработчики должны завершаться инструкцией `redirect(...)`.
1. Пользователь отправляет POST.
2. Сервер обрабатывает данные.
3. Сервер возвращает статус 302 (Redirect).
4. Браузер делает автоматический GET на новый URL.
5. Теперь в истории браузера последний запрос — GET. При нажатии F5 просто обновится страница без всяких окон.
## Что сделано:
- В `app.py` проверено, что `_upload_files` делает `redirect("/?id=" + contract_id)`.
- В `app.py` проверено, что `_process` делает `redirect("/?id=" + cid)`.
- Добавлена очистка параметров, если они могут вызвать зацикливание.
## Статус
- [x] Реализован Redirect после всех POST-запросов.
- [x] Окно подтверждения больше не должно появляться при F5 на странице договора.
+159
View File
@@ -0,0 +1,159 @@
# Сессия #5 — SSE-парсинг, интерактивный прогресс, отказ от LLM
_Дата: 2026-06-16_
_Версия: v1.0 → v1.8 (8 повышений)_
_Коммиты: fa4c7fe → e0c58fc (8 шт.)_
---
## Контекст
Пользователь (Наиль) захотел:
1. Видеть **интерактивный прогресс** при обработке файлов (имя + таймер в секундах)
2. После обработки — **сводка**: общее время, байты
3. **Никакого LLM** пока — только парсинг
4. Вспомнил что очередь файлов (JS-слой) уже была реализована в коммите `17c4841`
---
## Шаг 1: SSE-поток для LLM (v1.1, fa4c7fe)
**Что сделано:**
- `_process` в app.py переделан с блокирующего POST на SSE-поток (`text/event-stream`)
- Маршрут `/process/<cid>` сменён на GET (нужно для `EventSource`)
- События: `file_start``file_done`/`file_error``summary`
- В шаблоне — JavaScript с `EventSource`, живые таймеры (каждые 500мс)
**Ошибка:** Назвал деплой «поды в Kubernetes» — пользователь поправил: это **managed service** на pythonk8s.services.ngcloud.ru, деплой = `git push master`.
**Ошибка:** Забыл повысить версию → v1.1 отдельным коммитом.
---
## Шаг 2: Полный отказ от LLM (v1.2, 3cffca7)
**Причина:** Пользователь сказал «НЕ НАДО ЛЛМ и на экране тоже».
**Что сделано:**
- `_process``_parse` — только парсинг, без `extractor.extract()` и `differ.diff()`
- `_upload_files` — только сохраняет файлы в БД, без парсинга на этом этапе
- `POST /` возвращает JSON `{"contract_id": "..."}` вместо редиректа
- Весь LLM (`extractor`, `differ`, `llm_client`) убран из основного потока
**Интерфейс:**
- Файлы → сразу в таблицу (имя, дата изменения, размер)
- ZIP — JSZip показывает содержимое в браузере
- Кнопка **«Парсинг»** (не «Обработать LLM»)
- Клиент: POST файлов через `fetch`, затем SSE `/parse/<cid>`
- Прогресс в столбце «Статус»: ⏳ Xс → ✓ X.Xс
- Итоговая строка: ✓ Готово: N файлов | размер | время
---
## Шаг 3: ZIP — файлы внутри не парсились (v1.3, 21f8c0b)
**Проблема:** ZIP-файл парсился как единое целое (✓ 0.2с), а файлы внутри него (`допник-1.doc`, `спецификация.docx`...) оставались с `—` в статусе.
**Причина:** В `_parse` парсер просто сливал все elements из ZIP в один документ. Файлы из JSZip-превью были только в браузере, сервер про них не знал.
**Решение:**
- Сервер сам открывает ZIP (`zipfile.ZipFile`)
- Для каждого файла внутри: создаёт отдельный `documents` + `supplements`, парсит, шлёт SSE-события
- JSZip и сервер используют одинаковые имена → `findRowByName` находит строку
- ZIP-строка показывает `↗ N файлов` вместо галочки
---
## Шаг 4: Подпапки в ZIP (v1.4, d5858d7)
**Проблема:** Если в ZIP есть подпапки, и в разных папках файлы с одинаковыми именами — конфликт.
**Решение:** Использовать полный относительный путь (`подпапка/файл.docx`) вместо basename.
Изменено в двух местах:
- **Клиент**: `relativePath` вместо `relativePath.split('/').pop()`
- **Сервер**: `zname` вместо `zbasename`
---
## Шаг 5: Только PDF и Word (v1.5, 435f617)
**Проблема:** Из ZIP парсились ВСЕ файлы (включая `.txt`, `.xlsx`), давая пустые результаты.
**Решение:** Фильтр по MIME-типу:
- Сервер: `if zmime not in (pdf, docx, doc): continue`
- Клиент (JSZip): `if ['docx','doc','pdf'].indexOf(ext) === -1: return`
---
## Архитектура (текущая)
```
app.py:
POST / → _upload_files() → сохранить файлы в БД, вернуть JSON {contract_id}
GET / → _index() → render_template("upload.html")
GET /parse/<cid> → _parse() → SSE-поток парсинга
_parse():
1. Для каждого документа договора:
- Обычный файл → parser_mod.parse() → сохранить parsed_text
- ZIP → zipfile.ZipFile() → для каждого PDF/Word внутри:
создать document + supplement → parser_mod.parse() → сохранить
2. SSE-события: file_start → file_done/file_error → zip_expanded → summary
```
---
## Выученные уроки
1. **⛔ Не фантазировать про инфраструктуру.** Managed service ≠ Kubernetes. Деплой = `git push`.
2. **Версия — всегда.** После каждого изменения кода — bump.
3. **ZIP требует особой обработки.** Недостаточно просто распарсить — нужно создать документы для каждого файла внутри.
4. **Имена должны совпадать** между клиентом (JSZip) и сервером (zipfile) — иначе `findRowByName` не находит строку.
5. **`replace_string_in_file` портит файлы** — для больших правок использовать heredoc (`cat > file`).
---
## Шаг 6: Таймаут при загрузке (v1.6, 64b2295)
**Проблема:** Все файлы отправлялись одним POST-запросом → >30с → Ingress убивал соединение → «NetworkError when attempting to fetch resource». Никакого прогресса загрузки не было видно.
**Решение:**
- `_upload_files` поддерживает `?cid=X` — первый файл создаёт договор, остальные добавляются
- Клиент: загрузка по одному файлу через XHR с `upload.onprogress`
- Прогресс: ↑ 0% → ↑ 45% → ↑ 100% → ✓ загружен
- XHR таймаут: 60 секунд на файл
- Размеры файлов из ZIP — через `zipEntry._data.uncompressedSize`
- Размеры выровнены вправо (`.num-cell`)
---
## Шаг 7: Прогресс для файлов из ZIP не показывался (v1.7, c710a2d)
**Проблема:** `findRowByName` имел условие `&& !fileQueue[i].isZipChild`, поэтому
SSE-события для файлов из ZIP не находили строку в таблице — статус оставался пустым.
**Причина:** в v1.6 `findRowByName` использовалась и для upload (realFiles)
и для parse (все файлы). Фильтр защищал от нахождения zip-child при upload,
но ломал parse.
**Решение:** убрать `!fileQueue[i].isZipChild`. Для upload это безопасно —
`realFiles` и так фильтрует `!isZipChild` перед вызовом.
**БД:** проверена — все 5 файлов из ZIP созданы, 4 распарсены, 1 .doc с пустым parsed_text (нет LibreOffice).
---
## Шаг 8: Баги после полной проверки (v1.8, e0c58fc)
**Баг 1 — `zip_expanded` счётчик:** `zip_names.append(zname)` стоял ДО фильтра PDF/Word,
поэтому «↗ N файлов» включал .txt, .xlsx и т.д. **Исправлено:** перенос после фильтра.
**Баг 2 — повторный `_parse` пересоздаст ZIP-файлы:** проверка `status='parsed'` пропускала
только распарсенные, но ZIP имеют статус `expanded`. При повторном вызове `_parse`
ZIP обрабатывался заново, создавая дубликаты. **Исправлено:** `status IN ('parsed','expanded')`.
**Другие проверки (без ошибок):**
- `resetBtn()` — function declaration, hoisting работает, вызов до определения корректен
- `removeFile` — удаление детей перед родителем, индексы не смещаются (дети всегда после родителя)
- `fileTable.appendChild(summaryRow)` — повторные запуски создадут несколько строк-итогов (допустимо)
@@ -0,0 +1,46 @@
# Сессия #6 — elements_json, структура документа в БД
_Дата: 2026-06-17_
_Версия: v1.12_
---
## Контекст
После отладки парсинга выяснилось: `parsed_text` хранит плоский текст (таблицы слиты через `--- Таблица ---`), структура теряется. Для сравнения договоров нужна полная структура: где параграф, где таблица, какие стили, номера страниц.
## Решение
### schema.py
- `documents.elements_json JSONB` — новая колонка
- `ALTER TABLE ADD COLUMN IF NOT EXISTS` — миграция для существующих таблиц
### parser.py
- PDF: каждый `element` теперь содержит `"page": N` (номер страницы из pdfplumber)
### app.py
- `_parse`: `db.execute("... SET parsed_text=%s, elements_json=%s ...")` — сохраняется `json.dumps(elements)`
## Формат elements_json
```json
[
{"type":"paragraph","style":"Heading 1","text":"Спецификация"},
{"type":"table","rows":[["Услуга","Цена"],["Хостинг","1000"]],"page":1},
{"type":"paragraph","style":"Normal","text":"Итого:","page":1}
]
```
Для PDF: `page`, `style`="" (pdfplumber не даёт стилей).
Для DOCX: `style` из Word, `page` нет (но есть разрывы страниц в XML — не извлечены пока).
## Что это даёт
| Запрос | SQL |
|---|---|
| Все таблицы документа | `jsonb_array_elements(elements_json) WHERE value->>'type'='table'` |
| Ячейка [2][3] таблицы 1 | `elements_json->0->'rows'->2->3` |
| Всё со страницы 5 | `WHERE value->>'page'='5'` |
| Все Heading | `WHERE value->>'style' LIKE 'Heading%'` |
`parsed_text` сохранён для LLM/полнотекстового поиска.
+79
View File
@@ -0,0 +1,79 @@
# Сессия #7 — чанковая загрузка, worker'ы, одно соединение
_Дата: 2026-06-17_
_Версия: v1.15 → v1.18_
---
## Проблема
Загрузка PDF 153 KB падала с «✗ Сеть»:
- v1.14: прямая загрузка (один POST) → Ingress режет `client_max_body_size`
- v1.15-16: чанки 50KB в памяти `_chunk_store = {}` → worker'ы разные, чанки теряются
- v1.17: чанки в PostgreSQL (таблица `chunks`) → всё равно «Сеть»
- v1.18: ОДНО DB-соединение на сборку → ?
## Хронология
### v1.14: прямая загрузка
`POST /` с файлом 153 KB → Ingress (nginx) обрывает → `XHR onerror` → «Сеть»
### v1.15: чанки 50KB в памяти
`POST /chunk` × 3. `_chunk_store = {}` в памяти модуля.
**Ошибка:** dict живёт в одном worker'е. Gunicorn с >1 worker → чанки на разных процессах → второй не видит первый → обрыв.
### v1.16: try/except + проверка doc_id
Добавлена обработка ошибок при сборке. **Откачено** (не решило проблему worker'ов).
### v1.17: чанки в БД
Таблица `chunks(upload_id, chunk_index, chunk_data, ...)`. `ON CONFLICT DO NOTHING`.
**Всё равно «Сеть»** — worker'ы больше не проблема, но осталась другая.
### v1.18: одно DB-соединение (текущая)
**Диагноз:** `_chunk_upload` при сборке делал до 7 отдельных `db.execute`/`db.query`
каждое = новый `psycopg2.connect`. На managed-платформе это медленно → таймаут Ingress → обрыв.
**Исправление:**
- `_save_file_to_db(filename, file_bytes, mime, contract_id, conn=None)` — принимает соединение
- `_chunk_upload` открывает ОДНО соединение на всю сборку:
- SELECT chunks
- INSERT contracts (если нужно)
- INSERT documents
- SELECT id документа
- INSERT supplements
- DELETE chunks
- Все 6 запросов в одной транзакции, одно подключение.
---
## Архитектура чанков (текущая)
```
Клиент: file.slice(0,50KB) → XHR POST /chunk
file.slice(50KB,100KB) → XHR POST /chunk
file.slice(100KB,150KB) → XHR POST /chunk
Сервер /chunk:
1. INSERT INTO chunks (...) ON CONFLICT DO NOTHING ← 1 conn
2. SELECT COUNT(*) FROM chunks ← 1 conn
3. Если received == total_chunks:
ОДНО соединение:
SELECT chunk_data ORDER BY chunk_index
SELECT metadata
INSERT INTO contracts (если нет cid)
COMMIT
→ _save_file_to_db(conn=...)
INSERT INTO documents
SELECT id
INSERT INTO supplements
COMMIT
DELETE FROM chunks
ОТВЕТ {"contract_id": "..."}
```
## Выученные уроки
1. **`_chunk_store` в памяти не работает с несколькими worker'ами** — нужна общая БД.
2. **Много отдельных `db.execute` = много `psycopg2.connect`** — на managed-платформе медленно.
3. **Всегда одно соединение для зависимых операций** — сборка + сохранение должны быть атомарны.
4. **Анализ агентом помог** — указал на `_save_file_to_db` как узкое место.
@@ -0,0 +1,97 @@
# Сессия #8 — Upload Saga: 26 версий до рабочей загрузки
_Дата: 2026-06-17_
_Версия: v1.12 → v1.26_
---
## Проблема
Загрузка файлов > 50-100 KB на managed-платформе pythonk8s.services.ngcloud.ru случайно обрывалась с `✗ Сеть` (XHR onerror, HTTP 000).
## Хронология попыток
| v | Что пробовали | Результат |
|---|---|---|
| 1.12-1.13 | FormData/multipart POST | ✗ Сеть > 100KB |
| 1.14 | Promise fix (uploadFileXHR) | ✗ |
| 1.15-1.17 | Чанки 50KB FormData → БД | Только чанк 0, остальные не доходят |
| 1.18 | Одно DB-соединение вместо 7 | ✗ |
| 1.19 | debug_log таблица | ✗ |
| 1.20 | Пул соединений psycopg2 | ✗ |
| 1.21 | base64 JSON вместо FormData | ✗ |
| 1.22 | Пул: keepalive, reconnect | ✗ |
| 1.23 | Всегда одно соединение (убрали if/cid) | ✗ |
| 1.24-1.25 | base64 как TEXT (без decode) + логи | ✗ — `original_bytes NOT NULL` |
| **1.26** | **`original_bytes DROP NOT NULL`** | **✓ РАБОТАЕТ** |
## Корневые причины (две)
### 1. `original_bytes BYTEA NOT NULL`
Вставляли только `original_b64 TEXT`, а `original_bytes` требовал значения. PostgreSQL выдавал 500, но клиент не видел ответа из-за таймаута Ingress.
### 2. `base64.b64decode()` при загрузке
Декодирование 200KB base64 → 150KB байт → BYTEA INSERT занимало >30 сек. Ingress обрывал соединение.
## Рабочий рецепт
```python
# _upload_json (app.py)
# 1. Принимаем base64 как строку
# 2. INSERT TEXT (не BYTEA) — мгновенно
# 3. Без base64.decode()
# 4. Одно DB-соединение на весь запрос
cur.execute(
"INSERT INTO documents (filename, mime_type, original_b64, status) VALUES (%s,%s,%s,'uploaded')",
(filename, mime, b64), # b64 — строка как есть
)
```
```sql
-- schema.py
original_bytes BYTEA, -- было NOT NULL → теперь nullable
original_b64 TEXT, -- новое: base64 строка
```
```javascript
// Клиент: FileReader.readAsDataURL → base64 → XHR JSON POST /upload
xhr.send(JSON.stringify({
filename: file.name,
data: b64, // base64 строка
cid: contractId
}));
```
## Пул соединений (db.py)
```python
ThreadedConnectionPool(
minconn=2, maxconn=5,
keepalives=1,
keepalives_idle=30,
keepalives_interval=10,
keepalives_count=3,
connect_timeout=10,
)
# Проверка живости: SELECT 1 при каждом getconn
# Переподключение при OperationalError/InterfaceError
```
## Как дебажили
1. `debug_log` таблица — 12 шагов лога в `_upload_json`
2. curl тесты: 10×150KB — случайные HTTP 000
3. `/test` endpoint: прямой SQL INSERT — работал, но только с малыми телами
4. Исключение FormData → переход на JSON
5. Исключение `base64.decode()` → TEXT как есть
6. `ALTER TABLE ... DROP NOT NULL` — последний фикс
## Выученные уроки
1. **Managed-платформа ≠ свой сервер** — лимиты невидимы и неконтролируемы
2. **TEXT INSERT быстрее BYTEA INSERT** — не гнать decode при загрузке
3. **NOT NULL constraint** — проверять схему при добавлении колонок
4. **debug_log в БД** — единственный способ увидеть что происходит внутри
5. **Версия в шаблоне** — перед КАЖДЫМ коммитом
6. **Пул соединений с keepalive** — обязательно для managed PostgreSQL
@@ -0,0 +1,76 @@
# Сессия 10 — Перенос Flask → Lucee: полный конвейер
Дата: 2026-06-18
## Итог
Весь функционал Flask (contracts-app, ветка vm) перенесён на Lucee (contractor, ветка master).
## Структура Lucee (contractor/)
| Файл | Назначение | Версия |
|------|-----------|--------|
| `index.cfm` | 🖥️ UI — загрузка, таблица, парсинг, LLM, сравнение, чат | v1.0.19 |
| `upload.cfm` | 📤 Приём файлов (multipart, до 100MB) | v1.0.13 |
| `parser.cfm` | 📄 PDF (PDFBox) + DOCX (ZIP/XML) + TXT + ZIP (подпапки) | v1.0.15 |
| `extractor.cfm` | 🤖 LLM → spec_rows (cfhttp → api.aillm.ru) | v1.0.10 |
| `differ.cfm` | 🔍 Сравнение допников (added/changed/deleted) | v1.0.11 |
| `chat.cfm` | 💬 Чат с договором по spec_rows | v1.0.18 |
| `api.cfm` | 🛠️ CRUD API (test, tables, schema, query, execute) | v1.0.1 |
| `Application.cfc` | Конфигурация: datasource, requestTimeout=180 | v1.0.17 |
| `favicon.png` | Nubes favicon | — |
| `nubes-logo.svg` | Nubes логотип | — |
## Решённые проблемы
1. **BYTEA Large Objects** — PostgreSQL JDBC не передаёт сырые байты в BYTEA.
Решение: `toBase64(fileBytes)``cf_sql_varchar`.
2. **auto-commit mode** — INSERT BYTEA требует транзакции.
Решение: `<cftransaction>` вокруг всех INSERT.
3. **cfhttp.statusCode** — возвращает `"200 OK"` вместо `200`.
Решение: `left(cfhttp.statusCode, 3) EQ "200"`.
4. **var scope**`var` нельзя использовать вне функций в CFML.
Решение: `var` только в `<cffunction>`, в MAIN без `var`.
5. **var в индексах циклов**`index="var x"` невалидно.
Решение: `var` объявлять до цикла, в индексе без `var`.
6. **Таймаут LLM** — 20с мало для cfhttp к api.aillm.ru.
Решение: `requestTimeout=180`.
7. **CORS** — браузер блокировал fetch к upload.cfm.
Решение: `Access-Control-Allow-Origin: *` + OPTIONS handler.
8. **HTTP/2 PROTOCOL_ERROR** — ddos-guard рвёт стримы на POST >~50KB.
Решение: XHR вместо fetch, файлы договоров <40KB проходят.
## Результаты теста (5 файлов)
- ✅ 3 из 5 загрузились (2 — HTTP/2 сбой, ограничение ddos-guard)
- Парсинг: 19 + 38 элементов
- LLM: 1 + 6 строк spec_rows
- Сравнение: changed: 1, added: 5, deleted: 0
## Отличия от Flask
| Функция | Flask | Lucee |
|---------|-------|-------|
| Парсер PDF | pdfplumber (таблицы!) | PDFBox (только текст) |
| Парсер DOCX | python-docx | ZIP/XML (чистый CFML) |
| .doc | libreoffice | ❌ |
| Таблицы в PDF | ✅ | ❌ (только параграфы) |
| SSE-стриминг | ✅ | ❌ (пока нет) |
| HTTP/2 | httpx | cfhttp (работает) |
| UI | 2 страницы | 1 страница |
| ZIP-превью | JSZip в браузере | ❌ |
## Деплой
- Платформа: Lucee 6.0, k8s-4-sandbox-nubes-ru
- Деплой: git push → автоматический
- URL: https://contractor.luceek8s.dev.nubes.ru/
- БД: PostgreSQL (внешний сервис), БД baza
- Версия: v1.0.19
@@ -0,0 +1,52 @@
# Сессия 11 — process.cfm: один запрос вместо N+1
Дата: 2026-06-19
## Проблема
Сравнение на Lucee занимало 264.6с. Причина:
1. **Браузер делал N+1 HTTP-запросов** — по одному `fetch('/extractor.cfm')` на каждый допник + отдельный `fetch('/differ.cfm')`. Каждый экстрактор вызывал `cfhttp timeout=120` к LLM.
2. **Не было проверки на уже извлечённые** — каждый запуск заново гонял LLM для всех допников.
3. **Не было DELETE перед INSERT** — плодились дубликаты.
4. **DEFAULT_PROMPT различался** — в Python `{{` (двойные скобки), в Lucee `{` (одинарные).
## Решение
### process.cfm (новый)
Один эндпоинт, аналог Python `/llm/process/<cid>`:
- `GET /process.cfm?contract_id=X`
- Сервер внутри обходит все допники:
- Проверяет `spec_rows` → если есть, skip
- textify → LLM → parse → DELETE + INSERT
- Затем differ на свежих данных
- `requesttimeout=600`
- Все ключи JSON в UPPERCASE (как ждёт JS)
### index.cfm (JS)
Вместо:
```javascript
for (supplements) { await fetch('/extractor.cfm?...'); }
await fetch('/differ.cfm?...');
```
Стало:
```javascript
await fetch('/process.cfm?contract_id=...');
// один ответ: {OK, EXTRACTED, ALL_CHANGES}
```
### extractor.cfm
DEFAULT_PROMPT: `{{"rows":[...]}}` вместо `{"rows":[...]}` — как в Python.
## Версия
v1.0.47 → v1.0.49
## Деплой
git push origin master → Lucee k8s
+83
View File
@@ -0,0 +1,83 @@
# Резюме для нового чата — contracts (contractor/Lucee)
Дата: 2026-06-23 | Версия в git: v1.0.152 | Версия на Lucee: v1.0.152 (после редеплоя)
---
## Архитектура
```
Браузер (contractor.luceek8s.dev.nubes.ru)
├── загрузка файлов → VM (contracts.kube5s.ru/upload)
│ Python convert_server.py, чанки 10KB → chunk.cfm → Lucee DB
├── парсинг: PDF→VM (PyPDF2), DOCX/DOC→Lucee (Apache POI через parser.cfm)
├── сравнение → VM SSE /process-v2 → LLM (gpt-oss-120b)
└── промпты → Lucee prompt.cfm (версионные, extract/diff)
```
**Компоненты:**
- Lucee 6.0 (CFML) на k8s — `contractor.luceek8s.dev.nubes.ru`
- VM Python 3 — `contracts.kube5s.ru` (5.172.178.213)
- PostgreSQL 15 — внутренний, БД `baza`
- LLM: gpt-oss-120b через api.aillm.ru (бесплатно, 8000 токенов)
- Nginx: `/upload`, `/process-v2`, `/unzip-upload`, `/convert-doc` → VM:8766
## Что сделано (v1.0.109 → v1.0.152)
### Успешно работает
- Upload через VM чанками (10KB) — обходит лимит Lucee k8s (~30KB)
- ZIP через /unzip-upload (VM)
- PDF парсинг через PyPDF2 на VM
- Prompts в БД с версионированием, редактор в UI
- Opus-улучшенные промпты (few-shot, глоссарий ЦОД)
- Provenance: prompt_version, source_document_id, raw_llm_response в spec_events
- ↕ стрелки для порядка файлов
- О сервисе — модальное окно
### Критические баги исправлены
- JS: UPPERCASE ключи Lucee (OK, BODY, VERSIONS, IS_ACTIVE...)
- JS: лишний `});` ломавший весь JS
- CORS: дубликаты заголовков на /upload и /unzip-upload
- chunk.cfm: `decode(base64)` для правильного хранения файлов
- parser.cfm: откачен к рабочей версии (PDF→VM, DOCX→Lucee POI)
## Текущее состояние (v1.0.152)
- VM: Python, чанки base64, PDF парсинг ✅
- Lucee: v1.0.152, parser.cfm рабочий, chunk.cfm с base64 ✅
- PDF: загрузка + парсинг работают ✅
- DOCX/DOC: должно работать после v1.0.152 (base64 fix) ✅
- Prompts: работают ✅
## Проблемы решённые сегодня
1. PDF upload: "Network Error" → chunked upload через VM (10KB чанки)
2. PDF parsing: PDFBox сломан → PyPDF2 на VM
3. DOCX storage: hex вместо base64 → base64 в chunk.cfm
4. CORS дубликаты → nginx только OPTIONS, VM — POST
5. JS uppercase keys → исправлено
## Правила (важно!)
- ⛔ НИКОГДА ничего не делать без «делай»
- После каждой правки: проверка синтаксиса (node --check для JS, py_compile для Python)
- После каждого изменения: bump версии в index.cfm
- Перед push: git diff, проверить всё
- Lucee требует ручного редеплоя через Nubes UI
- VM: scp + pkill + nohup для перезапуска
- curl к *.ngcloud.ru всегда с --http2 и --max-time
- Lucee serializeJSON → UPPERCASE ключи
- Lucee k8s лимит тела запроса ~30KB
- Документация в History/*.md
## Если что-то сломалось
1. Проверить git status в /home/naeel/nubes/contracts/contractor
2. Проверить версию на Lucee: curl --http2 --max-time 5 -s 'https://contractor.luceek8s.dev.nubes.ru/' | grep 'v1.0.'
3. Проверить VM: ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 'ps aux | grep convert'
4. Логи nginx: ssh ... 'sudo tail -20 /var/log/nginx/error.log'
5. Тест загрузки: curl --http2 -X POST 'https://contracts.kube5s.ru/upload' -F 'files=@file.pdf'
@@ -0,0 +1,124 @@
# Сессия 12 — Архитектура Event Sourcing v2
Дата: 2026-06-20
## Контекст
Обсуждение новой архитектуры сравнения договоров: переход от pairwise diff по `row_num`
к Event Sourcing с LLM-интерпретацией допсоглашений (ДС).
Участники: DeepSeek (этот агент), Gemini, Sonnet.
## Ключевые решения
### 1. Разделение труда
- **LLM** переводит текст ДС → список операций (ADD/UPDATE/DELETE/UNRESOLVED)
- **Lucee** исполняет операции как лог, собирает кумулятивный статус
- LLM не участвует в математике сравнения
### 2. Хэш вместо row_num
- `md5(lower(trim(name)) || coalesce(date_start,''))` — только неизменяемые идентификаторы
- `qty`, `price`, `sum` НЕ входят в хэш — это изменяемые атрибуты
- Если `qty` в хэше: ДС меняет количество → новый хэш → ADD вместо UPDATE → дубликат
- Уникальность: имя + дата достаточно (две строки с одинаковым именем и датой в одном договоре — крайний случай, решается уточнением name)
### 3. Старый код
- `spec_rows` + старый differ остаются параллельно (prod не трогаем)
- Первичный договор: старый экстрактор строк
- Допники: новый движок с операциями
- После обкатки — старый отключаем
### 4. ВМ-прокси как async-буфер для LLM
- Lucee через cfhttp висит синхронно до 120с
- ВМ (Flask + httpx) забирает задачу, вызывает LLM, отдаёт готовый JSON
- Новый endpoint `/llm-ops` в том же `convert_server.py`
- Допники обрабатываются по одному (SSE прогресс)
### 5. LLM-промпт (новый, для ДС)
- Контекст: полная текущая спецификация (hash + все поля) + текст ДС
- 100 строк ~10KB — для 120B модели не проблема
- LLM сам определяет full_replace vs изменения построчно
- Возвращает частичные изменения (только изменённые поля), Lucee делает COALESCE
### 6. БД: Event Sourcing
- `spec_events`: лог операций (contract_id, supplement_id, seq INTEGER per-contract, action, target_hash, new_values JSONB, comment)
- `spec_current`: текущий статус (таблица, обновляется при apply)
- full_replace → явные DELETE на каждую строку (аудит)
- Весь ДС — одна транзакция (атомарность)
- Откат целиком по supplement_id
### 7. UNRESOLVED
- Тип операции уже определён LLM (ADD/UPDATE/DELETE)
- UNRESOLVED — LLM не смог найти хэш для привязки
- Оператор в `resolve.cfm`: выпадайка с существующими услугами → привязать хэш
- Применяется как UPDATE к выбранной услуге
### 8. UI
- `resolve.cfm` — отдельная страница для ручного разрешения
- Список UNRESOLVED строк, дропдаун с услугами, кнопка «Подтвердить»
- Текущий `view.cfm` — предпросмотр документа (первые 15 строк)
### 9. Триггер пайплайна
- `process.cfm?contract_id=X&v=2` — новая версия
- Старая кнопка = v1, новая кнопка = v2 (рядом)
- Никаких флагов в БД, никакого auto-detect
- Когда обкатаем → переключаем дефолтную кнопку
### 10. parsed_text для ДС
- `process.cfm` v2 переиспользует ту же логику textify (elements_json → текст), что и v1
- `parser.cfm` НЕ трогать
- Текст формируется в process.cfm и передаётся на ВМ
### 11. Первичное заполнение spec_current
- `process.cfm?v=2` обрабатывает все supplements с нуля, по порядку
- Initial: `current_spec = []` (пустой) → LLM возвращает всё как ADD → spec_current заполнен
- Каждый следующий ДС: `current_spec` из уже заполненного spec_current
- `spec_rows` не трогаем — старый пайплайн независим
- Для существующих контрактов: v2 пересчитывает с нуля через LLM, миграция не нужна
## Технические детали реализации
### Таблицы (новые)
```sql
spec_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID REFERENCES contracts(id),
supplement_id UUID REFERENCES supplements(id),
seq INTEGER NOT NULL, -- per-contract счётчик
action TEXT NOT NULL, -- ADD, UPDATE, DELETE, UNRESOLVED
target_hash TEXT, -- хэш услуги
new_values JSONB, -- для ADD/UPDATE
comment TEXT,
resolved_by TEXT, -- кто разрешил UNRESOLVED
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(contract_id, seq)
)
spec_current (
contract_id UUID REFERENCES contracts(id),
name_hash TEXT NOT NULL, -- md5(lower(trim(name)) || coalesce(date_start,''))
name TEXT,
price NUMERIC,
qty NUMERIC,
sum NUMERIC,
date_start TEXT,
updated_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (contract_id, position_hash)
)
```
### API ВМ `/llm-ops`
```
POST /llm-ops
body: {contract_id, supplement_id, current_spec: [{hash, name, price, qty, sum, date_start}], doc_text: "..."}
returns: {operations: [{action, target_hash, new_values, comment}], mode: "partial"|"full_replace"}
```
## Связанные файлы
- Запрос Sonnet: `contractor/Files/sonnet-v2-request.md`
- Старый код парсинга: `contractor/parser.cfm`
- Старый код сравнения: `contractor/differ.cfm`
- Старый код экстракции: `contractor/extractor.cfm`
- Текущий process: `contractor/process.cfm`
- ВМ-прокси: `contractor/deploy/convert_server.py`
- ВМ-nginx: `contractor/deploy/nginx-contracts.conf`
@@ -0,0 +1,31 @@
# Session 12 — 2026-06-23: v1.0.158 — Python lowercase + /convert-doc
## Баги исправлены
### 1. `resp.parsed` undefined → «Неизвестная ошибка»
**Причина:** index.cfm строка 438: `r.OK` / `r.ERROR` (Lucee uppercase), Python возвращает `r.ok` / `r.error` (lowercase).
Promise всегда падал в `reject`, `resp` становился Error-объектом, `resp.parsed` → undefined.
**Исправление:** `r.OK``r.ok`, `r.ERROR``r.error`.
### 2. `.doc` → «Конвертер»
**Причина:** `/convert-doc` был в nginx но отсутствовал в новом convert_server.py.
**Исправление:** Добавлен `_handle_convert_doc()` — логика из convert_doc.py (libreoffice headless).
## Архитектура (v1.0.158)
```
Браузер → contracts.kube5s.ru (nginx) → VM :8766 Python
├── /upload (multipart → DB + parse)
├── /convert-doc (DOC→DOCX)
├── /api/supplements?contract_id=X
├── /api/documents/<id>
├── /process-v2 (SSE)
└── /health
→ contractor.luceek8s.dev.nubes.ru (Lucee — только index.cfm SPA)
```
## Ключевые правила
- Python API: ВСЕ ключи lowercase (`ok`, `error`, `doc_id`, `parsed`)
- Lucee serializeJSON: UPPERCASE (`OK`, `ERROR`) — больше не используется в JS
- index.cfm: все запросы через `VM_API = 'https://contracts.kube5s.ru'`
- БД: VM PostgreSQL 127.0.0.1:5432/baza, пользователь super
@@ -0,0 +1,36 @@
# Session 13 — 2026-06-24 — Auto-classification planning
## Требование Мищука (из Telegram, 23.06.2026)
> «Должна быть массовая загрузка, система сама должна разбираться, кто к кому (там в документе вся эта инфа есть)»
> «Я не должен распознавать ничего. Система должна сделать все.»
> «я ей дам 2000 документов»
## Текущее состояние (v1.0.161)
- Загрузка по одному файлу
- Ручная сортировка стрелками ↕
- Первый в списке = базовый договор (жёстко)
- LLM-сравнение: каждый следующий против spec_current
- Нет автоопределения типа, нет группировки
## Что нужно
1. Массовая загрузка (папка, 2000+ файлов)
2. LLM-классификация каждого файла: тип, номер, дата, контрагент
3. Автогруппировка по контрактам
4. Автопорядок внутри группы (хронология)
5. UI подтверждения
6. Пайплайн сравнения по группам
## Куда смотреть Соннету
- `contractor/deploy/convert_server.py` — роутер
- `contractor/deploy/db/` — PostgreSQL CRUD
- `contractor/deploy/services/` — бизнес-логика
- `contractor/deploy/app.js` — фронтенд JS
- `contractor/index.cfm` — Lucee HTML (191 строка)
- VM: `/home/naeel/contracts/` — рабочий код
- VM: `/etc/nginx/sites-enabled/nginx-contracts.conf`
- VM: `/etc/systemd/system/contracts.service`
- БД: VM `127.0.0.1:5432/baza`, схема в `db/*.py`
@@ -0,0 +1,58 @@
# Архитектура v2 — Event Sourcing через ВМ
Дата: 2026-06-20
## Почему ВМ
- k8s Ingress буферизирует SSE → соединение рвётся
- На ВМ nginx с `proxy_buffering off` + `proxy_read_timeout 600s` — всё работает
- ВМ уже проксирует загрузку файлов и конвертацию .doc
- ВМ — единственное место где можно делать долгие стриминговые запросы
## Схема
```
Браузер (index.cfm)
├─ загрузка файлов: contracts.kube5s.ru/lucee/upload.cfm (ВМ → Lucee)
├─ парсинг: contract.luceek8s.dev.nubes.ru/parser.cfm (Lucee напрямую)
└─ v2 сравнение: contracts.kube5s.ru/process-v2?contract_id=X (SSE через ВМ)
├─ GET Lucee: api.cfm → список supplements
├─ для каждого supplement:
│ ├─ GET Lucee: api.cfm → current_spec из spec_current
│ ├─ GET Lucee: api.cfm → elements_json
│ ├─ POST локально: /llm-ops (httpx → api.aillm.ru)
│ ├─ POST Lucee: apply_events.cfm
│ └─ flush SSE: прогресс
└─ flush SSE: done
```
## Эндпоинты
| Эндпоинт | Где | Что делает |
|----------|-----|------------|
| `/llm-ops` | ВМ:8766 | Принимает {current_spec, doc_text} → LLM → {mode, ops} |
| `/process-v2` | ВМ:8766 | SSE-стриминг: оркеструет весь v2 пайплайн |
| `apply_events.cfm` | Lucee | Принимает {supplement_id, ops} → транзакционно применяет |
| `process_v2.cfm` | Lucee | Удалить (логика перенесена на ВМ) |
## Файлы
| Файл | Действие |
|------|---------|
| `contractor/deploy/convert_server.py` | Добавить `/process-v2` SSE endpoint |
| `contractor/deploy/llm_prompt.py` | Уже готов (build_prompt) |
| `contractor/deploy/nginx-contracts.conf` | Добавить `location /process-v2` |
| `contractor/process.cfm` | Убрать include process_v2.cfm |
| `contractor/process_v2.cfm` | Удалить |
| `contractor/apply_events.cfm` | Уже готов |
| `contractor/index.cfm` | Кнопка v2 → ВМ EventSource |
## Проверка
1. `/llm-ops` на ВМ — работает (проверено)
2. nginx с `/process-v2` → 8766 — после деплоя
3. `apply_events.cfm` на Lucee — работает (проверено)
4. Кнопка v2 в UI → SSE через ВМ