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
+161
View File
@@ -0,0 +1,161 @@
# Архитектура Contracts App
## Обзор
Сервис **Сверка договоров** — автоматизированная обработка договоров и допников (docx/pdf)
с извлечением структурированных данных, отслеживанием изменений и восстановлением
истории договора во времени.
## Принципы
1. **НЕ МОНОЛИТ** — каждый слой независим, отдельный файл, своя зона ответственности
2. **Данные не покидают облако** — всё в PostgreSQL внутри кластера
3. **Ничего не терять** — парсер отдаёт полный слепок документа, LLM решает что важно
4. **Исключения — не фантазировать** — нерешаемые подзадачи отмечать явно
---
## Слои приложения
```
┌─────────────────────────────────────────────┐
│ app.py │
│ ContractsApp (сборка) │
├──────────┬──────────┬──────────┬────────────┤
│ db.py │ parser.py│ test_ │ (будущие) │
│ (БД) │ (парсинг)│ routes.py │ llm.py │
│ │ │ (/test) │ upload.py│
└──────────┴──────────┴──────────┴────────────┘
```
| Слой | Файл | Что делает | Статус |
|---|---|---|---|
| Ядро | `app.py` | Flask-приложение, инициализация, регистрация Blueprint | ✅ |
| БД | `db.py` | `connect()`, `query()`, `_pg_connect()` | ✅ |
| Тесты | `test_routes.py` | Blueprint `/test` — мост к БД извне | ✅ |
| Парсер | `parser.py` | `parse(bytes, mime) → elements` для docx/pdf/doc/zip | ✅ |
| LLM | `llm.py` | Нормализация строк через aillm.ru (120B) | ⬜ |
| Загрузка | `upload.py` | Приём файлов, сохранение в БД | ⬜ |
---
## Поток обработки документа
```
Пользователь
│
▼
POST /upload (файл .docx/.pdf/.doc/.zip)
│
▼
upload.py: сохранить в contract_docs (original_bytes)
│
▼
parser.py: parse(bytes, mime) → elements JSON
│
▼
db.py: сохранить parsed_json в contract_docs
│
▼
llm.py: отправить elements → LLM → нормализованные spec_rows
│
▼
db.py: сохранить в spec_rows, сравнить с предыдущими → spec_history
│
▼
GET /contract/{id}/history → полная история изменений
```
---
## Схема БД (план)
```
contract_docs — исходные файлы + сырой парсинг
id UUID PK
contract_id → contracts.id
filename TEXT
mime_type TEXT
original_bytes BYTEA ← сам файл
parsed_json JSONB ← выдача parser.py
created_at TIMESTAMPTZ
contracts — договоры
id UUID PK
number TEXT ← номер договора
client TEXT ← клиент
date DATE
status TEXT
supplements — допники
id UUID PK
contract_id → contracts.id
number TEXT
date DATE
type TEXT ← новый / изменение / расторжение
doc_id → contract_docs.id
spec_rows — строки спецификаций
id UUID PK
supplement_id → supplements.id
row_num INT
name TEXT ← наименование услуги
price NUMERIC
qty NUMERIC
sum NUMERIC
date_start DATE
date_end DATE
spec_history — история изменений
id UUID PK
spec_row_id → spec_rows.id
supplement_id → supplements.id
change_type TEXT ← added / changed / deleted / unchanged
old_values JSONB
new_values JSONB
```
---
## API эндпоинты
| Метод | Путь | Слой | Что |
|---|---|---|---|
| GET | `/` | app.py | Главная (HTML) |
| GET | `/health` | app.py | Health check → "OK" |
| GET | `/test` | test_routes | Статус БД + список команд |
| POST | `/test` `{"action":"status"}` | test_routes | Статус БД |
| POST | `/test` `{"action":"createdb"}` | test_routes | Создать БД contracts |
| POST | `/test` `{"action":"tables"}` | test_routes | Список таблиц |
| POST | `/test` `{"action":"sql","sql":"..."}` | test_routes | Произвольный SQL |
---
## Технологии
| Компонент | Выбор |
|---|---|
| Язык | Python 3.12 |
| Фреймворк | Flask |
| БД | PostgreSQL (внутрикластерный) |
| Парсинг docx | python-docx |
| Парсинг .doc | LibreOffice (headless) |
| Парсинг PDF | pdfplumber |
| LLM | aillm.ru API (120B модель) |
| Деплой | pythonk8s.services.ngcloud.ru |
| Репозиторий | gitea.services.ngcloud.ru/Nail/contracts-app.git |
---
## Конфигурация (переменные окружения)
| Переменная | Назначение |
|---|---|
| `DB_HOST` | Хост PostgreSQL |
| `DB_PORT` | Порт (5432) |
| `DB_NAME` | Имя БД (contracts) |
| `DB_USER` | Пользователь |
| `DB_PASS` | Пароль |
| `DB_SSLMODE` | SSL mode (disable) |
| `LLM_API_KEY` | Ключ aillm.ru (будет) |
| `LLM_API_URL` | URL LLM API (будет) |
+69
View File
@@ -0,0 +1,69 @@
# Блок-схема сервиса Contracts
## Полный пайплайн обработки допника
```mermaid
flowchart TD
A["📄 POST /api/contracts/{id}/supplements\n(file.docx)"] --> B["💾 documents.original_bytes\n(status=uploaded)"]
B --> C["🔧 parser.parse()\nbytes → elements JSON"]
C --> D["📝 textify.to_text()\nelements → линейный текст"]
D --> E["📄 documents.parsed_text\n(status=parsed)"]
E --> F["🤖 extractor.extract()\nтекст → LLM → строки JSON"]
F --> G["📊 spec_rows\n(row_num, name, price, qty, sum, date)"]
G --> H["🔄 differ.diff()\nсравнение с пред. допником"]
H --> I["📋 spec_history\n(added/changed/deleted/unchanged)"]
I --> J["✅ ответ API\n{rows, diff_summary}"]
```
## Архитектура слоёв
```mermaid
flowchart LR
subgraph "Внешний мир"
USER["👤 Пользователь"]
end
subgraph "Flask"
APP["app.py\nсборка"]
API["api.py\n/contracts"]
UPL["upload.py\n/upload"]
TEST["test_routes.py\n/test"]
end
subgraph "Бизнес-логика"
PARSER["parser.py\nbytes→JSON"]
TEXTIFY["textify.py\nJSON→текст"]
LLM["llm_client.py\nHTTP/2→LLM"]
EXTRACT["extractor.py\nтекст→строки"]
DIFF["differ.py\nсравнение"]
end
subgraph "Данные"
DB["db.py\nconnect/query"]
SCH["schema.py\nDDL"]
PG[("PostgreSQL\n5 таблиц")]
end
USER -->|"POST docx"| API
USER -->|"GET /test"| TEST
API --> PARSER
PARSER --> TEXTIFY
TEXTIFY --> EXTRACT
EXTRACT --> LLM
EXTRACT --> DIFF
API --> DB
DB --> PG
APP --> SCH
SCH --> DB
```
## Цепочка данных
```mermaid
flowchart LR
A["docx\n(байты)"] -->|parser| B["elements\nJSON"]
B -->|textify| C["текст\nстрока"]
C -->|extractor + LLM| D["spec_rows\nJSON"]
D -->|differ| E["changes\nJSON"]
E -->|api| F["ответ\nпользователю"]
```
+101
View File
@@ -0,0 +1,101 @@
# Пайплайн обработки договора
## Схема
```
┌─────────────────────────────────────────────────────────────────────┐
│ 👤 ПОЛЬЗОВАТЕЛЬ │
│ Открывает / → выбирает файлы → жмёт «Обработать» │
└──────────────────────────┬──────────────────────────────────────────┘
│ multipart/form-data (files)
▼
┌─────────────────────────────────────────────────────────────────────┐
│ app.py: _upload_page() POST │
│ Принимает файлы, запускает пайплайн │
└──────────────────────────┬──────────────────────────────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
file.docx file.pdf file.zip
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ ① parser.py │
│ docx → python-docx → elements JSON │
│ pdf → pdfplumber → elements JSON │
│ doc → libreoffice → docx → python-docx │
│ zip → zipfile → каждый файл рекурсивно │
│ │
│ Выход: [{type:"paragraph", style, text}, {type:"table", rows}] │
│ НИЧЕГО не фильтрует, не теряет │
└──────────────────────────┬──────────────────────────────────────────┘
│ elements JSON
▼
┌─────────────────────────────────────────────────────────────────────┐
│ ② textify.py │
│ elements → линейный текст │
│ │
│ [No Spacing] Приложение № 1 │
│ [Heading 3] Состав и стоимость Услуг │
│ --- Таблица (9×6) --- │
│ | № | Наименование | Цена | Объем | Сумма | Дата | │
│ | 1 | Аренда стойко-места | 213 905 | 3 | 641 716 | 01.04.2026 | │
└──────────────────────────┬──────────────────────────────────────────┘
│ текст (строка)
▼
┌─────────────────────────────────────────────────────────────────────┐
│ ③ extractor.py + llm_client.py │
│ текст → промпт → LLM (gpt-oss-120b) → JSON │
│ │
│ Промпт: «Найди таблицы услуг, извлеки строки в JSON» │
│ Ответ: [{"row_num":1, "name":"Аренда...", "price":213905, │
│ "qty":3, "sum":641716, "date_start":"2026-04-01"}, ...] │
└──────────────────────────┬──────────────────────────────────────────┘
│ строки спецификации
▼
┌─────────────────────────────────────────────────────────────────────┐
│ ④ differ.py │
│ Сравнение с предыдущим допником │
│ │
│ rows_old vs rows_new → changes: │
│ added: новая услуга │
│ changed: цена 213905 → 250000, дата 01.04 → 25.04 │
│ deleted: услуга убрана │
│ unchanged: без изменений │
└──────────────────────────┬──────────────────────────────────────────┘
│ изменения
▼
┌─────────────────────────────────────────────────────────────────────┐
│ ⑤ Сохранение в PostgreSQL │
│ │
│ documents ← исходный файл + распарсенный текст │
│ contracts ← договор │
│ supplements ← допник │
│ spec_rows ← строки спецификации │
│ spec_history ← история изменений │
└──────────────────────────┬──────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Ответ пользователю: HTML-страница с таблицей результатов │
│ «Извлечено 14 строк, добавлено 14, изменено 0, удалено 0» │
│ + полная таблица услуг с ценами │
└─────────────────────────────────────────────────────────────────────┘
```
## Кратко
```
файлы → parser → textify → LLM → differ → PostgreSQL → HTML-страница
```
## Слои
| # | Слой | Что делает |
|---|---|---|
| ① | `parser.py` | docx/pdf/doc/zip → JSON |
| ② | `textify.py` | JSON → текст |
| ③ | `extractor.py` + `llm_client.py` | текст → LLM → строки |
| ④ | `differ.py` | сравнение с предыдущим |
| ⑤ | `db.py` + `schema.py` | сохранение в БД |
| — | `app.py` | сборка, приём формы, отдача HTML |
+60
View File
@@ -0,0 +1,60 @@
# Ответ: HTTP/2 из Python к aillm.ru
_Дата: 2026-06-14_
---
## Проблема
`api.aillm.ru` защищен ddos-guard, который требует наличия HTTP/2 в TLS-хендшейке от облачных IP. Библиотека `requests` работает только по HTTP/1.1, что приводит к ложным ошибкам 401/403.
## Решение
Использование библиотеки **`httpx`** с установленной поддержкой HTTP/2 через чистый Python-пакет **`h2`**.
### 1. Подготовка зависимостей (requirements.txt)
Так как облачная платформа не всегда корректно обрабатывает синтаксис `httpx[http2]`, необходимо прописать зависимости **явно отдельными строками**. Это гарантирует установку `h2` — pure Python реализации протокола, которая не требует системных библиотек или `apt`.
```text
# requirements.txt
httpx
h2
```
### 2. Код клиента (llm_client.py)
Для работы по HTTP/2 в `httpx` нужно явно передать параметр `http2=True` при создании клиента.
```python
import os
import httpx
def ask_llm(prompt: str):
api_url = "https://api.aillm.ru/v1/chat/completions"
api_key = os.getenv("LLM_API_KEY", "sk-ucI5YvOticoOQ9Kuj5K9mQ")
payload = {
"model": "gpt-oss-120b",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 8000,
"temperature": 0.1
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
# ВАЖНО: http2=True включает поддержку нужного протокола
with httpx.Client(http2=True, timeout=120) as client:
resp = client.post(api_url, json=payload, headers=headers)
resp.raise_for_status()
return resp.json()
```
## Почему это работает в slim-контейнере
1. **`httpx`** — современный клиент, поддерживающий HTTP/2.
2. **`h2`** — это замена системным библиотекам `nghttp2`. Она написана на чистом Python, поэтому корректно устанавливается через `pip` даже в максимально «обрезанных» образах (\*-slim, alpine) без компиляторов и `apt`.
3. **HTTP/2 Fingerprint** — ddos-guard видит поддержку HTTP/2 в TLS Client Hello, что является для него признаком «легального» клиента (браузера или современного SDK), а не простого скрипта на `requests` или старого бота.
## Резюме для реализации
- Используйте `httpx.Client(http2=True)`.
- Убедитесь, что `h2` есть в `requirements.txt` отдельной строкой.
- Не используйте `httpx[http2]` в конфигах, если платформа капризная.
+45
View File
@@ -0,0 +1,45 @@
# Вопрос: HTTP/2 из Python к aillm.ru
## Контекст
Flask-приложение в контейнере `python:3.12-slim`, деплоится через облачную платформу.
Нужно отправлять промпты к LLM API: `https://api.aillm.ru/v1/chat/completions`
(ключ: `sk-ucI5YvOticoOQ9Kuj5K9mQ`, модель: `gpt-oss-120b`).
## Проблема
`api.aillm.ru` за ddos-guard, который **требует HTTP/2 от облачных IP**.
С локальной машины работает любой HTTP-клиент (requests, curl без --http2),
но из облачного контейнера — только HTTP/2.
Что пробовали, что НЕ сработало:
- `requests` — HTTP/1.1, ddos-guard возвращает ложный 401
- `httpx[http2]` в requirements.txt — платформа не парсит `[http2]`, h2 не ставится
- `curl` через subprocess — curl не установлен в slim-контейнере, apt недоступен
- свой HTTP/2 клиент на h2+socket — слишком сложно, заказчик против
## Вопрос
**Как в Python из slim-контейнера сделать HTTP/2 POST-запрос к aillm.ru?**
Требования:
1. Никакого apt/curl — только pip
2. Просто, без велосипедов на сокетах
3. Работает в python:3.12-slim
4. Ключ передаётся как `Authorization: Bearer sk-ucI5YvOticoOQ9Kuj5K9mQ`
5. Тело: JSON с model, messages, max_tokens, temperature
Доступные модели на aillm.ru: `gpt-oss-120b`, `qwen3.6-27b-fp8`, `qwen3-6-27b-fp8-opt`, `whisper-large-v3-turbo`.
## Для справки
В Node.js это работает одной строкой:
```js
await fetch('https://api.aillm.ru/v1/chat/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'gpt-oss-120b', messages: [...] })
});
```
Нужен такой же простой способ в Python.
+145
View File
@@ -0,0 +1,145 @@
Read [](file:///home/naeel/nubes/contracts/contractor/apply_events.cfm)
Read [](file:///home/naeel/nubes/contracts/contractor/db.cfc#1-1), lines 1 to 150
Read [](file:///home/naeel/nubes/contracts/contractor/deploy/llm_prompt.py)
Read [](file:///home/naeel/nubes/contracts/contractor/deploy/convert_server.py#1-1), lines 1 to 150
Read [](file:///home/naeel/nubes/contracts/contractor/deploy/convert_server.py#150-150), lines 150 to 250
Проанализировал все файлы. Вот подробный аудит.
---
## Аудит Event Sourcing — сверка договоров
---
### Находки Gemini — подтверждение/опровержение
**1. Race condition `MAX(seq)` — ✅ ПОДТВЕРЖДЕНО, критично**
apply_events.cfm: `SELECT COALESCE(MAX(seq), 0)` → `seq++` в цикле — это read-modify-write без блокировки. Два параллельных вызова (разные supplements) получат одинаковый `MAX(seq)` и сгенерируют одинаковые seq. В DDL есть `UNIQUE(contract_id, seq)` — транзакция упадёт с ошибкой дублирующегося ключа.
**Рекомендация:** Заменить на `nextval` через sequence (`CREATE SEQUENCE spec_events_seq`) или PostgreSQL `INSERT ... RETURNING` с `seq = (SELECT COALESCE(MAX(seq),0)+1 FROM spec_events WHERE contract_id=... FOR UPDATE)` — с `FOR UPDATE` для пессимистической блокировки.
---
**2. `full_replace` с пустым `ops` стирает спецификацию — ✅ ПОДТВЕРЖДЕНО, критично**
apply_events.cfm: Если LLM вернул `mode=full_replace` с пустым `ops[]` (например, не распознал таблицу), код сначала DELETE всех строк из `spec_current`, потом цикл по ops не выполняется. Спецификация обнулена без восстановления.
**Рекомендация:** Добавить guard: если `mode == full_replace` и `len(ops) == 0` — отклонить с ошибкой, не трогать `spec_current`.
---
**3. LLM возвращает строку вместо числа → `cf_sql_float` падает — ✅ ПОДТВЕРЖДЕНО**
apply_events.cfm: Переменные `pr`, `qt`, `sm` берутся напрямую из `op.new_row`. Промпт в llm_prompt.py говорит «ЧИСЛА, не строки», но LLM может вернуть `"1 000,00"` или `"null"` как строку. `cf_sql_float` с нечисловой строкой кидает исключение внутри транзакции — вся транзакция откатывается.
**Рекомендация:** Перед передачей в cfqueryparam привести к числу через `val()` или попытаться `javacast("double", pr)` с catch.
---
**4. N+1 SELECT в цикле UPDATE — ✅ ПОДТВЕРЖДЕНО**
apply_events.cfm: Для каждого UPDATE-op выполняется отдельный `SELECT price, qty, sum, date_start FROM spec_current WHERE name_hash=?`. Если ДС обновляет 50 строк — 50 SELECT-запросов внутри одной транзакции.
**Рекомендация:** Перед циклом собрать все `target_hash` UPDATE-ops, сделать один `SELECT ... WHERE name_hash = ANY(ARRAY[...])`, сложить результат в struct по hash.
---
**5. Построчный INSERT в `full_replace` — ✅ ПОДТВЕРЖДЕНО**
apply_events.cfm: В `full_replace`-блоке цикл по `curRows` выполняет по одному `INSERT INTO spec_events` на каждую строку. После — снова цикл по `ops`, каждый ADD — ещё 2 INSERT. Для 100-строчной спецификации: 100+100×2 = 300 отдельных INSERT внутри одной транзакции.
**Рекомендация:** Использовать `INSERT INTO spec_events ... SELECT unnest(...)` или сформировать multi-row VALUES через ColdFusion loop перед запросом.
---
**6. `name_hash` зависит от LLM-форматирования — ✅ ПОДТВЕРЖДЕНО, фундаментальная проблема**
apply_events.cfm: `md5(lower(trim(name)) || coalesce(date_start, ''))` вычисляется на стороне PostgreSQL из данных, которые LLM только что вернул. В llm_prompt.py: в `_build_diff` LLM видит `[hash: {hash}]` и должен вернуть этот же hash в `target_hash`. Но LLM может сократить название, изменить регистр или пробелы → `md5` будет другим → UPDATE-op не найдёт строку → `cfthrow "UPDATE target not found"`.
**Рекомендация:** Это структурная проблема. Варианты:
- Передавать LLM только `hash` как непрозрачный идентификатор (уже так и делается), но добавить fuzzy-matching на стороне apply: если `target_hash` не найден — искать по близкому имени и создавать UNRESOLVED вместо throw.
- Добавить отдельный endpoint для ручного разрешения UNRESOLVED.
---
**7. `last_event_id` UUID → `cf_sql_varchar` — ✅ ПОДТВЕРЖДЕНО**
apply_events.cfm: `<cfqueryparam value="#evt.id#" cfsqltype="cf_sql_varchar">` для колонки `last_event_id UUID`. Lucee отправит строку, PostgreSQL неявно приведёт к UUID — это работает, но неправильно. При включённом строгом режиме или нестандартном JDBC-драйвере может дать ошибку. Стоит использовать `cfsqltype="cf_sql_char"` с `maxlength="36"` или более явный тип.
---
### Упущенные проблемы
**8. SQL-инъекция в `_lucee_query` — КРИТИЧНО**
convert_server.py: Метод `_lucee_query` принимает готовую SQL-строку, которую формируют через f-string интерполяцию. Например:
```python
f"... WHERE s.contract_id='{cid}' ..."
```
`cid` приходит из GET-параметра `?contract_id=...` без какой-либо санитизации. Если атакующий передаст `cid = "'; DROP TABLE spec_current; --"` — запрос выполнится.
**Рекомендация:** Передавать `contract_id` как параметр через `params` API Lucee, а не конкатенацией в строку. Либо хотя бы проверять `cid` по regex UUID: `re.fullmatch(r'[0-9a-f-]{36}', cid)`.
---
**9. `elements_json` double-decode — баг надёжности**
convert_server.py:
```python
ej = docs[0]["elements_json"]
if isinstance(ej, dict) and "Value" in ej:
ej = ej["Value"]
elements = json.loads(ej)
```
Если Lucee вернул `elements_json` уже как распарсенный dict (без `"Value"` обёртки), то `json.loads(dict)` упадёт с `TypeError`. Условие `isinstance(ej, dict)` обрабатывает только Lucee-обёртку, но если `ej` уже список — код падает.
**Рекомендация:** `if isinstance(ej, (dict, list)): elements = ej if isinstance(ej, list) else json.loads(ej["Value"])`.
---
**10. `full_replace` DELETE не в правильном порядке в аудит-логе**
apply_events.cfm: DELETE-события для old строк записываются с `seq++` начиная с 0, затем ADD-события из `ops` продолжают seq. Это корректно. НО: если replay этих событий — сначала DELETE всего, потом ADD новых. Проблема: `UNIQUE(contract_id, seq)` не гарантирует порядок применения при replay, если seq не строго монотонный из-за прошлых partial-операций. Нет механизма replay/восстановления из событий.
**Рекомендация:** Либо добавить флаг `is_replayable` в архитектурный документ (явно задокументировать что replay не поддерживается), либо добавить `applied_at TIMESTAMPTZ` и `parent_seq` для правильного replay.
---
**11. LLM-ключ в открытом виде в коде**
convert_server.py: `LLM_KEY = "sk-ucI5YvOticoOQ9Kuj5K9mQ"` — hardcoded в файле. При любом git push ключ попадёт в историю.
**Рекомендация:** Перенести в переменную окружения `os.environ.get("LLM_KEY")`.
---
**12. Нет идемпотентности — повторный вызов дублирует данные**
Нет проверки что `supplement_id` уже обработан. Если `/process-v2` вызвать дважды для одного контракта — все operations применятся повторно: ADD-строки задублируются (или упадут на `UNIQUE name_hash`), seq увеличится.
**Рекомендация:** Добавить `status` колонку в `supplements` (`processed` / `pending`) и проверять в начале цикла. Либо проверять `SELECT COUNT(*) FROM spec_events WHERE supplement_id = ?` перед обработкой.
---
### Итоговая таблица приоритетов
| # | Проблема | Критичность |
|---|----------|-------------|
| 8 | SQL-инъекция в `_lucee_query` | 🔴 Критично |
| 1 | Race condition MAX(seq) | 🔴 Критично |
| 2 | full_replace + empty ops → потеря данных | 🔴 Критично |
| 12 | Нет идемпотентности | 🟠 Высокая |
| 6 | name_hash нестабилен | 🟠 Высокая |
| 3 | LLM строка вместо числа → crash | 🟠 Высокая |
| 11 | LLM-ключ в коде | 🟡 Средняя |
| 9 | elements_json double-decode | 🟡 Средняя |
| 4 | N+1 SELECT в UPDATE | 🟡 Средняя |
| 5 | Построчный INSERT в full_replace | 🟢 Низкая |
| 7 | UUID как cf_sql_varchar | 🟢 Низкая |
| 10 | Нет механизма replay | 🟢 Низкая |
@@ -0,0 +1,210 @@
# Ответ: архитектура сервиса "Сверка договоров"
_Дата: 2026-06-13_
---
## 1. Правильно ли разбиты слои?
Разбивка в целом правильная. Принцип «один файл — одна ответственность» выдержан.
Критических проблем нет, но есть два момента, которые стоит учесть.
### Что хорошо
- `parser.py` — чисто I/O-слой: байты → JSON. Никакой логики.
- `textify.py` — чисто форматирование: JSON → текст. Никакой логики.
- `db.py` — чисто транспорт к БД. Не знает о бизнес-сущностях.
- `test_routes.py` — отдельный Blueprint, не засоряет app.py.
### Что стоит скорректировать
**`db.py` — разделить на транспорт и схему.**
Сейчас там `ensure_db()` — это уже «знание» о схеме. Когда появятся таблицы
(`contracts`, `supplements`, `spec_rows`, `spec_history`), их создание (DDL)
стоит вынести в отдельный `schema.py`. `db.py` остаётся просто `connect()` и `query()`.
**Слой LLM стоит разделить надвое:**
- `llm_client.py` — HTTP-клиент к aillm.ru: отправить промпт → получить строку ответа.
Не знает ни о договорах, ни о спецификациях.
- `extractor.py` — бизнес-логика: взять текст договора, сформировать промпт,
вызвать llm_client, распарсить ответ в строки спецификации.
Это важно: если поменяется LLM — меняем только `llm_client.py`.
Если поменяется формат ответа — только `extractor.py`.
**Итоговый состав слоёв:**
```
parser.py bytes → elements JSON (уже есть, не трогать)
textify.py elements → текст для LLM (уже есть, не трогать)
db.py connect() + query() (уже есть, убрать ensure_db)
schema.py DDL: CREATE TABLE IF NOT EXISTS
upload.py сохранить файл в БД (documents)
llm_client.py HTTP к aillm.ru → строка ответа
extractor.py текст → структурированные строки (промпт + парсинг ответа)
differ.py сравнение строк между допниками → список изменений
api.py Blueprint: /contracts, /supplements, /history
app.py сборка слоёв, Flask-приложение
test_routes.py Blueprint /test (уже есть)
```
---
## 2. В каком порядке создавать
Каждый этап самодостаточен и проверяем до перехода к следующему.
### Этап 1 — основа хранения
`schema.py` → DDL всех таблиц.
Запустить `python schema.py` — таблицы созданы. Проверить через `/test sql`.
### Этап 2 — загрузка файлов
`upload.py` → принять файл, вызвать `parser.parse()`, вызвать `textify.to_text()`,
сохранить в `documents(original_bytes, parsed_text, mime, filename)`.
Добавить POST `/upload` в `app.py`. Проверить curl-ом.
### Этап 3 — LLM клиент
`llm_client.py` → POST к aillm.ru, вернуть строку.
Проверить отдельно: `python llm_client.py` с тестовым промптом.
### Этап 4 — извлечение строк спецификации
`extractor.py` → взять `parsed_text`, сформировать промпт, вызвать `llm_client`,
распарсить ответ в список `spec_row`.
Проверить на одном docx через тест-скрипт.
### Этап 5 — сравнение (diff)
`differ.py` → взять два списка `spec_row`, вернуть изменения.
Это чистая функция: `diff(rows_old, rows_new) → changes`.
Проверить unit-тестом без БД.
### Этап 6 — API
`api.py` → Blueprint с GET/POST для договоров, допников, истории.
Подключить в `app.py`.
---
## 3. Поток данных между слоями
Правило: слои передают данные через простые Python-структуры (dict, list).
Никаких прямых вызовов «через слой» — только соседние слои.
```
Файл (bytes)
│
▼
parser.parse(bytes, mime) → {"elements": [...]}
│
▼
textify.to_text(elements) → str
│
▼
upload.py: сохранить в DB, получить document_id
│
▼
extractor.extract(parsed_text) → [{"pos": 1, "name": "...", "qty": 10, ...}]
│ (внутри вызывает llm_client.ask(prompt) → str)
│
▼
schema: сохранить строки в spec_rows(document_id, pos, ...)
│
▼
differ.diff(rows_v1, rows_v2) → [{"pos": 3, "field": "qty", "old": 5, "new": 10}]
│
▼
schema: сохранить в spec_history
```
Между слоями **нет импортов друг друга**, кроме:
- `upload.py` импортирует `parser` и `textify` (это нормально — upload оркеструет парсинг)
- `extractor.py` импортирует `llm_client` (клиент — зависимость экстрактора)
- `app.py` и `api.py` импортируют всё — они и есть точки сборки
`db.py` никто не импортирует напрямую, кроме `upload.py`, `extractor.py` и `api.py`.
`schema.py` вызывается только один раз при старте из `app.py`.
---
## 4. Как должен выглядеть app.py
`app.py` — точка входа и сборки. Бизнес-логики ноль.
```python
from flask import Flask
import db, schema
from test_routes import test_bp
from api import api_bp
def create_app():
app = Flask(__name__)
# 1. Инициализация схемы при старте
schema.ensure_schema()
# 2. Регистрация Blueprint-ов
app.register_blueprint(test_bp)
app.register_blueprint(api_bp)
# 3. Системные маршруты
@app.route("/health")
def health():
return "OK", 200
return app
if __name__ == "__main__":
create_app().run(host="0.0.0.0", port=5000)
```
Правило: если в `app.py` появляется `if`, `for` или бизнес-слово — это уже лишнее.
---
## 5. Потенциальные проблемы
### LLM не гарантирует структуру ответа
Самая острая проблема. Модель может вернуть текст в произвольном формате,
сломать JSON, пропустить поля, придумать данные.
**Решение:**
- В `extractor.py` — строгая схема промпта с примером ответа.
- Парсинг ответа через `try/except` с явным возвратом `{"error": "parse_failed", "raw": ответ}`.
- Никогда не падать — помечать строки как `unresolved`.
### Идентификация изменённой строки в допнике
Самая неоднозначная задача: иногда в допнике новое полное состояние,
иногда — дельта. Определить это автоматически сложно.
**Решение для `differ.py`:**
- Сначала попробовать детерминированный diff по позиции/артикулу.
- Если совпадение < порога — пометить как `ambiguous`, не фантазировать.
- Заказчик потом разбирает вручную `ambiguous`-записи.
### Сопоставление артикула с каталогом
Задача нетривиальная, код-имён в спецификациях нет, матч только по описанию.
**Решение:** вынести в отдельный `matcher.py`, реализовать как отдельный шаг после основного пайплайна. Пометить как `optional`, не блокировать основной поток.
### Размер документов vs контекст LLM
Большая спецификация (100+ строк) может не влезть в контекст.
**Решение в `extractor.py`:** разбивать таблицы на чанки, обрабатывать частями,
собирать результат. Это нужно заложить сразу — переделывать потом дороже.
### Транзакционность при загрузке
Файл загружен → парсинг ок → LLM вызов → ошибка → документ в БД наполовину.
**Решение:** хранить в `documents` поле `status` (`uploaded` / `parsed` / `extracted` / `error`).
Обновлять после каждого шага. Зависший `uploaded` — сигнал для повтора.
---
## Итог
| Вопрос | Ответ |
|--------|-------|
| Разбивка слоёв | Правильная. Добавить `schema.py`, разделить LLM на `llm_client` + `extractor` |
| Порядок | schema → upload → llm_client → extractor → differ → api |
| Поток данных | Через dict/list, нет перекрёстных импортов |
| app.py | Только сборка: `ensure_schema()` + `register_blueprint()` |
| Риски | LLM-нестабильность, diff-амбивалентность, чанкинг, транзакционность |
Всё, что не решается надёжно детерминированно — помечать `unresolved`, не фантазировать.
@@ -0,0 +1,81 @@
# Запрос к Claude Sonnet: архитектура сервиса "Сверка договоров"
## Контекст
Мы строим Flask-сервис для автоматизированной обработки договоров и допников.
Исходная постановка задачи (от заказчика):
1. Спецификации договоров разобрать до структурированного вида
2. Собрать из **цепочки допников кумулятивный статус договора** — состояние во времени.
Важно: иногда в допнике **новое состояние** договора целиком, иногда — **только изменения**,
и нужно идентифицировать изменённую строку.
3. (опционально, малый процент случаев) сопоставить артикул по описанию с каталогом услуг
(кодов в спецификациях нет)
Критичные ограничения:
- Данные строго конфиденциальны → LLM только своя (aillm.ru 120B), данные не покидают облако
- На каждом этапе возможны исключения — **не фантазировать**, а отметить конкретную
элементарную подзадачу как нерешаемую
- Рассматриваем как задачку для ИИ
## Ключевое требование заказчика
**НЕ МОНОЛИТИТЬ.** Всё делать небольшими НЕЗАВИСИМЫМИ слоями.
Каждый слой — отдельный Python-файл, своя зона ответственности.
Слои не должны зависеть друг от друга (или минимально).
## Что уже сделано
```
site/
├── app.py ← Flask-приложение, класс ContractsApp, только сборка слоёв
├── db.py ← слой БД: connect(), query(), _pg_connect()
├── test_routes.py ← Blueprint /test (статус БД, список таблиц, SQL, createdb)
├── parser.py ← слой парсера: parse(bytes, mime) → полный слепок docx/pdf
├── textify.py ← слой: elements JSON → линейный текст для LLM
├── static/
└── templates/
```
База: PostgreSQL, всё внутри облачного кластера.
LLM: aillm.ru (120B модель).
## Что предстоит сделать
1. **Слой загрузки** — приём файлов через API, сохранение в БД (original_bytes + parsed_text)
2. **Слой LLM** — отправка текста → модель → структурированные строки спецификации
3. **Слой хранения** — таблицы contracts, supplements, spec_rows, spec_history
4. **Слой сравнения (diff)** — сравнение строк между допниками, выявление изменений
5. **Слой API** — отдача истории договора, списка, etc.
## Вопрос
Разъясни план архитектуры:
1. Правильно ли разбиты слои? Может что-то объединить или наоборот — разделить?
2. В каком порядке их создавать?
3. Как организовать поток данных между слоями, чтобы они оставались независимыми?
4. Как должен выглядеть основной оркестратор (app.py) — без бизнес-логики?
5. Какие потенциальные проблемы ты видишь с таким подходом?
## Текущий код для ознакомления
### parser.py (bytes → JSON elements)
Использует python-docx для docx, libreoffice для .doc, pdfplumber для PDF, zipfile для zip.
Возвращает полный слепок: все абзацы (со стилями) + все таблицы (все строки).
Ничего не фильтрует, не теряет.
### textify.py (JSON elements → текст)
Форматирует elements в линейный текст: параграфы как `[Style] text`, таблицы как `| cell | cell |`.
Тоже не фильтрует, только форматирует.
### db.py (БД)
connect() в целевую БД, _pg_connect(dbname) в любую, query(sql) — выполнить произвольный запрос.
### test_routes.py (/test Blueprint)
GET /test — статус БД, POST /test с действиями: status, tables, sql, createdb.
Мост к БД извне для отладки.
## Ожидаемый формат ответа
Структурированный план архитектуры с пояснениями по каждому пункту вопроса.
+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 на странице договора.