Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba10816230 | ||
|
|
119d09beb7 | ||
|
|
71df83bc63 | ||
|
|
2ba92208a5 | ||
|
|
0ec26a2b8c | ||
|
|
21e5473045 | ||
|
|
69e69c7534 | ||
|
|
e16a8032b7 | ||
|
|
570a575a33 | ||
|
|
b406b0faf6 | ||
|
|
4c2d3903f9 | ||
|
|
a6b7a596ed | ||
|
|
5e2b630563 | ||
|
|
f2be570e87 | ||
|
|
3ff35ea438 | ||
|
|
b5755e166f | ||
|
|
f4d5bc7a55 | ||
|
|
93904b8b12 | ||
|
|
6bb8308fec | ||
|
|
091f458d86 | ||
|
|
0d532ce8f7 | ||
|
|
752d76e399 | ||
|
|
8d54bebbc8 | ||
|
|
87178fa3e7 | ||
|
|
b164a457ba | ||
|
|
e28e1a2186 | ||
|
|
f51dc70db6 | ||
|
|
878e8663fc | ||
|
|
804504926e | ||
|
|
fa8c83096e | ||
|
|
08d6a90b86 | ||
|
|
1c85c913f8 | ||
|
|
82c5c075f1 | ||
|
|
4a21d77f51 | ||
|
|
2e04731ac1 | ||
|
|
3e548ad2a4 | ||
|
|
c022133de0 | ||
|
|
36f31c2e23 | ||
|
|
a803c1d30f | ||
|
|
752f642aa0 | ||
|
|
a811e58f2b | ||
|
|
1545bcf31b | ||
|
|
e61d1a19cd | ||
|
|
35dba76d58 | ||
|
|
ebe29eef85 | ||
|
|
f7ac7d7ded | ||
|
|
fd50e6d5ec | ||
|
|
366a87b79e | ||
|
|
1135400d5b | ||
|
|
f8c3871cdf | ||
|
|
1d167a180f | ||
|
|
2668fb7a38 | ||
|
|
72e6e5a127 | ||
|
|
0045157173 | ||
|
|
c40d8eb5a8 | ||
|
|
14cca9f8cf | ||
|
|
bec73b6a35 | ||
|
|
0c1b6cb033 | ||
|
|
1af49d1ca5 | ||
|
|
038fe1c966 | ||
|
|
00d49d5e32 | ||
|
|
15da0ccbc4 | ||
|
|
c8ba93191b | ||
|
|
f6dec810fc | ||
|
|
8fd409ef0f | ||
|
|
af1fed1077 | ||
|
|
d960e2cdfc |
+4
-1
@@ -1,3 +1,6 @@
|
|||||||
FILES
|
FILES/
|
||||||
contracts-app/
|
contracts-app/
|
||||||
dogovora/
|
dogovora/
|
||||||
|
testgen/out/
|
||||||
|
testgen/out_100files/
|
||||||
|
contracts-flask/hz/
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Запрос к Опусу — анализ и план развития Contracts App (v1.0.178)
|
||||||
|
|
||||||
|
Дата: 26.06.2025. **Только инструкции, код не менять.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Контекст: что за сервис
|
||||||
|
|
||||||
|
Сервис **«Сверка договоров»** — обработка договоров облачного провайдера НУБЕС с контрагентами.
|
||||||
|
Пользователь загружает ZIP-архивы с документами (договоры, допсоглашения, спецификации),
|
||||||
|
система классифицирует, группирует по номерам договоров и сравнивает
|
||||||
|
спецификации услуг — показывает diff (ADD/UPDATE/DELETE/UNRESOLVED).
|
||||||
|
|
||||||
|
**Архитектура:**
|
||||||
|
- **Lucee (CFML)** — фронтенд, домен contractor.luceek8s.dev.nubes.ru
|
||||||
|
- **Python (VM)** — бэкенд, домен contracts.kube5s.ru, порт 8766
|
||||||
|
- **PostgreSQL** — хранение
|
||||||
|
- **nginx** — прокси
|
||||||
|
- **LLM** — api.aillm.ru, модель gpt-oss-120b, HTTP/2 через httpx
|
||||||
|
|
||||||
|
**Пайплайн:**
|
||||||
|
1. Upload (.docx/.pdf/.doc/.zip) → парсинг (python-docx/pdfplumber) → elements_json
|
||||||
|
2. Classify — LLM определяет: тип (contract/supplement/specification/other),
|
||||||
|
own_number, parent_number, counterparty, doc_date
|
||||||
|
3. Group — Python normalize_number() + matching по номеру договора
|
||||||
|
4. Compare — SSE, LLM сравнивает спецификации: ADD/UPDATE/DELETE/UNRESOLVED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Текущий код — ключевые файлы
|
||||||
|
|
||||||
|
### 2.1. Classify
|
||||||
|
|
||||||
|
**Файл:** `contractor/deploy/services/classify.py`
|
||||||
|
|
||||||
|
- `MAX_WORKERS = 4` — параллельные запросы к LLM
|
||||||
|
- `_smart_extract()` — выжимка текста: заголовок ~1500 симв + regex-хиты по маркерам
|
||||||
|
- `_call_llm_classify()` — вызов LLM, парсинг JSON из ответа
|
||||||
|
- `classify_batch()` — ThreadPoolExecutor, классифицирует все pending документы
|
||||||
|
- `_safe_json_parse()` — чинит битый JSON из LLM (markdown, trailing commas)
|
||||||
|
|
||||||
|
**Промпт classify:** `llm_prompt.py` → `build_classify_prompt(header_text)`.
|
||||||
|
Запрашивает у LLM:
|
||||||
|
```json
|
||||||
|
{"doc_type": "contract|supplement|specification|other",
|
||||||
|
"own_number": "XXX001-03700",
|
||||||
|
"parent_number": "XXX001-03700", // для допников/спек
|
||||||
|
"doc_date": "2025-01-01",
|
||||||
|
"counterparty": "ООО \"Ромашка\""}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Проблема classify:** при 50+ файлах синхронный вызов таймаутился.
|
||||||
|
Сейчас: `subprocess.Popen` → classify_worker.py (async, 202 Accepted).
|
||||||
|
Но фронтенд (`app.js` `runClassify()`) не полностью синхронизирован с async-ответом.
|
||||||
|
|
||||||
|
### 2.2. Group
|
||||||
|
|
||||||
|
**Файл:** `contractor/deploy/services/grouping.py`
|
||||||
|
|
||||||
|
- `normalize_number()` — uppercase + только буквы/цифры.
|
||||||
|
«МЭС-123/2024» == «МЭС 123/2024» после нормализации.
|
||||||
|
- `group_documents()` — иерархический алгоритм:
|
||||||
|
1. contract → якорь группы
|
||||||
|
2. supplement/spec → matching по parent_number (нормализованный)
|
||||||
|
3. Оставшиеся → виртуальные группы по own_number
|
||||||
|
4. Без номеров → `__unresolved__`
|
||||||
|
- `apply_groups()` — создаёт записи в DB (contracts + supplements)
|
||||||
|
|
||||||
|
**Известные баги normalize_number:**
|
||||||
|
- Кириллическая `О` vs цифра `0` — не различаются
|
||||||
|
- Латинская `C` vs кириллическая `С` — не различаются
|
||||||
|
- `Ё` → выпадает
|
||||||
|
|
||||||
|
### 2.3. Compare
|
||||||
|
|
||||||
|
**Файлы:** `contractor/deploy/convert_server.py` (SSE endpoint `/process-v2`),
|
||||||
|
`contractor/deploy/compare.js` (фронтенд SSE-клиент).
|
||||||
|
|
||||||
|
**Промпты compare:** `llm_prompt.py`
|
||||||
|
- `FALLBACK_EXTRACT` — для первого документа (базовый договор/спецификация):
|
||||||
|
ADD всех строк.
|
||||||
|
- `FALLBACK_DIFF` — для допсоглашений: UPDATE/DELETE/ADD/UNRESOLVED.
|
||||||
|
Поддерживает два режима:
|
||||||
|
- `"mode": "partial"` — точечные изменения
|
||||||
|
- `"mode": "full_replace"` — полная замена спецификации
|
||||||
|
|
||||||
|
**Проблема compare:** оба режима — «ADD всех строк». LLM не всегда корректно
|
||||||
|
сопоставляет строки между v1 и v2 спецификации.
|
||||||
|
|
||||||
|
### 2.4. ZIP handling
|
||||||
|
|
||||||
|
**Файлы:** `contractor/deploy/files.js` (`addZipFile()`),
|
||||||
|
`contractor/deploy/convert_server.py` (`_handle_unzip_upload()`).
|
||||||
|
|
||||||
|
Текущая логика:
|
||||||
|
1. ZIP загружается через FormData на `/unzip-upload`
|
||||||
|
2. Бэкенд распаковывает, возвращает base64 каждого файла
|
||||||
|
3. Фронтенд для каждого: base64 → Blob → File → upload (как addRegularFile)
|
||||||
|
4. Дубликаты по имени: confirm-диалог, перезапись
|
||||||
|
|
||||||
|
**Чего нет:**
|
||||||
|
- `zip_source` — связь файла с родительским ZIP
|
||||||
|
- Группировка в таблице по ZIP-источнику
|
||||||
|
- ID файла = `zip_source + "/" + filename`
|
||||||
|
|
||||||
|
### 2.5. Фронтенд
|
||||||
|
|
||||||
|
**Ключевые JS-модули:** `contractor/deploy/` — state.js, files.js, groups.js, compare.js, app.js, app_utils.js
|
||||||
|
|
||||||
|
**Таблица файлов:** `files.js` `renderFiles()` — плоский список, без группировки по ZIP.
|
||||||
|
Уже есть `max-height: 50vh; overflow-y: auto` (скролл).
|
||||||
|
|
||||||
|
**Степпер:** `app.js` `renderStepper()` — 4 шага: Загрузка → Классификация → Группировка → Сравнение.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Что сказал заказчик (26.06.2025)
|
||||||
|
|
||||||
|
> «А что касается вводных — файлы могут быть в зипах. Как правило, по 1 контрагенту,
|
||||||
|
> но я считаю, что по контрагенту может быть несколько зипов, и теоретически
|
||||||
|
> могу представить ситуацию, когда будет в одном зипе по нескольким как-то
|
||||||
|
> связанным контрагентам (какие-то агентские схемы). Наверно, вероятность того,
|
||||||
|
> что в одном зипе один контрагент — весьма высокая, но не 100%.»
|
||||||
|
|
||||||
|
Также обсуждалось:
|
||||||
|
- Привязка файлов к родительскому ZIP
|
||||||
|
- В таблице — ZIP как группирующий заголовок, ниже со сдвигом — вложенные файлы
|
||||||
|
- ID файла = имя зипа + "/" + имя файла
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Что обсуждали мы (ключевые выводы)
|
||||||
|
|
||||||
|
1. **НУБЕС — всегда Исполнитель.** Контрагенты — Заказчики.
|
||||||
|
2. **Один batch = один контрагент (почти всегда).**
|
||||||
|
Хоть 1 зип, хоть 5 зипов — всё по одному контрагенту.
|
||||||
|
3. **Агентские схемы — исключение**, не основная логика.
|
||||||
|
4. **`zip_source`** — для визуальной группировки в таблице.
|
||||||
|
Не влияет на логику classify/group/compare.
|
||||||
|
5. **Два сценария UI:**
|
||||||
|
- «Точный» (≤100 файлов) — текущий UI с таблицей и ручным контролем
|
||||||
|
- «Поток» (100+) — упрощённый UI: прогресс-бар, авто-пайплайн, сводный отчёт
|
||||||
|
(без таблицы — 1000 DOM-строк вешают браузер)
|
||||||
|
6. **Коллизия одинаковых имён из разных ZIP:**
|
||||||
|
- ID = `zip_source + "/" + filename` → разные сущности
|
||||||
|
- Compare покажет diff между версиями
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Что нужно от Опуса
|
||||||
|
|
||||||
|
### 5.1. ПЛАН по zip_source
|
||||||
|
|
||||||
|
Как именно внедрить привязку файлов к родительскому ZIP на ВСЕХ слоях:
|
||||||
|
- `unzip.py` → возвращать `zip_source`
|
||||||
|
- `db/documents.py` → поле `zip_source`
|
||||||
|
- `files.js` `renderFiles()` → группировка + отступ
|
||||||
|
- `state.js` → поле `zip_source`
|
||||||
|
- `index.cfm` / Lucee → надо ли менять?
|
||||||
|
|
||||||
|
**Варианты отображения:**
|
||||||
|
- Вариант А: ZIP как секция-заголовок, под ним файлы с отступом
|
||||||
|
- Вариант Б: древовидная структура (раскрывающиеся ZIP'ы)
|
||||||
|
- Вариант В: цветовая маркировка по ZIP
|
||||||
|
|
||||||
|
Какой лучше для пользователя и почему?
|
||||||
|
|
||||||
|
### 5.2. ПЛАН по двум сценариям UI
|
||||||
|
|
||||||
|
Как архитектурно разделить «Точный» и «Поток» режимы:
|
||||||
|
- Общий код (classify/group/compare не меняются)
|
||||||
|
- Разный UI: что показывать, что скрывать
|
||||||
|
- Как переключаться между режимами (авто по кол-ву файлов? ручной выбор?)
|
||||||
|
- «Поток» — сводный отчёт: структура, что в нём
|
||||||
|
|
||||||
|
### 5.3. АНАЛИЗ текущих промптов
|
||||||
|
|
||||||
|
Внимательно изучи `llm_prompt.py`:
|
||||||
|
- Есть ли проблемы в классификации (counterparty не определяется)
|
||||||
|
- Достаточно ли хорош diff-промпт (compare)
|
||||||
|
- Нужны ли разные варианты промптов для разных сценариев:
|
||||||
|
- Только НУБЕС-один контрагент (стандартный)
|
||||||
|
- Два контрагента (агентские схемы)
|
||||||
|
- Мусорные документы (акты сверки, счета)
|
||||||
|
- Нужна ли корректировка глоссария, примеров
|
||||||
|
|
||||||
|
### 5.4. РЕКОМЕНДАЦИИ по улучшению
|
||||||
|
|
||||||
|
Что ещё можно улучшить, исходя из анализа кода и требований заказчика:
|
||||||
|
- Приоритеты: что делать в первую очередь
|
||||||
|
- Риски: что может сломаться
|
||||||
|
- Оценка трудозатрат (грубо: маленькая/средняя/большая задача)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Ограничения
|
||||||
|
|
||||||
|
- **Код не менять.** Только инструкции и план.
|
||||||
|
- Ответ — в чат, подробно, с обоснованием каждого решения.
|
||||||
|
- Если есть несколько вариантов — перечислить с плюсами/минусами.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Несколько вопросов по сервису «Сверка договоров»
|
||||||
|
|
||||||
|
Чтобы настроить сервис точнее — пара коротких вопросов. Отвечать подробно не нужно,
|
||||||
|
достаточно отметить вариант или написать пару слов.
|
||||||
|
|
||||||
|
Это не финальный список: когда вы поработаете с сервисом, наверняка появятся
|
||||||
|
свои пожелания и вопросы — тогда обсудим остальное.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Объём
|
||||||
|
Сколько файлов обычно загружаете за один раз?
|
||||||
|
(нужно, чтобы решить — оставить подробную таблицу или сделать упрощённый режим для больших пачек)
|
||||||
|
|
||||||
|
- [ ] A. до 10–20
|
||||||
|
- [ ] B. 50–100
|
||||||
|
- [ ] C. 100+ (сотни/тысячи)
|
||||||
|
|
||||||
|
## 2. Как называется НУБЕС в договорах
|
||||||
|
В каждом договоре две стороны: вы (Исполнитель) и контрагент (Заказчик).
|
||||||
|
Сейчас система иногда путает, кто из них контрагент.
|
||||||
|
|
||||||
|
**Под какими названиями НУБЕС встречается в документах?**
|
||||||
|
(например: «ООО НУБЕС», «Облачные технологии», ИНН …)
|
||||||
|
|
||||||
|
> _ваш ответ:_
|
||||||
|
|
||||||
|
## 3. «Мусорные» документы
|
||||||
|
В архивах иногда попадаются не-договорные бумаги. Какие можно игнорировать при сверке?
|
||||||
|
|
||||||
|
- [ ] акты сверки
|
||||||
|
- [ ] счета / счета-фактуры / УПД
|
||||||
|
- [ ] акты оказанных услуг
|
||||||
|
- [ ] платёжные поручения
|
||||||
|
- [ ] другое: _______________
|
||||||
|
|
||||||
|
## 4. ZIP-архивы
|
||||||
|
Мы поняли так: обычно **один архив = один контрагент**, но по одному контрагенту
|
||||||
|
может быть несколько архивов, а изредка в одном архиве — несколько связанных
|
||||||
|
контрагентов (агентские схемы). **Всё верно?** Если есть нюансы — допишите.
|
||||||
|
|
||||||
|
> _ваш ответ:_
|
||||||
|
|
||||||
|
## 5. Что важнее всего в результате
|
||||||
|
На что смотрите в первую очередь, когда сверка готова?
|
||||||
|
(например: что изменилось в ценах, какие позиции не сопоставились, итоговая сумма…)
|
||||||
|
|
||||||
|
> _ваш ответ:_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Спасибо! Этого пока достаточно — остальное уточним по ходу.
|
||||||
@@ -0,0 +1,795 @@
|
|||||||
|
# Архитектурное исследование: Сверка договоров v2
|
||||||
|
|
||||||
|
**Дата:** 27.06.2026 | **Для:** DeepSeek V4 Pro | **По заказу:** Владимир Крупский
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Блок 1: Общая архитектура
|
||||||
|
|
||||||
|
### 1.1 Архитектура «с нуля»
|
||||||
|
|
||||||
|
Вот как бы я построил систему, зная все требования сейчас:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Ввод"
|
||||||
|
A[Файловая шара<br>/облачный диск]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Pre-processing Pipeline"
|
||||||
|
B["① Фильтр мусора<br>━━━━━━━━━━━━━<br>Детерминированная<br>(ключевые слова + regex<br>по первым 2KB текста)"]
|
||||||
|
C["② Парсинг документов<br>━━━━━━━━━━━━━<br>Python (pdfplumber + python-docx)<br>→ elements_json"]
|
||||||
|
D["③ Классификация<br>━━━━━━━━━━━━━<br>LLM (лёгкая модель)<br>+ _smart_extract<br>→ тип/номер/дата/контрагент"]
|
||||||
|
E["④ Группировка<br>━━━━━━━━━━━━━<br>Детерминированная Python<br>нормализация номеров + matching"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Core Processing"
|
||||||
|
F["⑤ Извлечение спецификации<br>━━━━━━━━━━━━━<br>LLM (основная модель)<br>контекст: полный текст<br>договора/спецификации<br>→ ADD ops"]
|
||||||
|
G["⑥ Сравнение ДС<br>━━━━━━━━━━━━━<br>LLM + Event Sourcing<br>контекст: текущая spec<br>+ текст ДС<br>→ ADD/UPDATE/DELETE/UNRESOLVED"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Сверка"
|
||||||
|
H["⑦ Matching CRM ↔ Фискальная<br>━━━━━━━━━━━━━<br>Гибрид: хеш-матчинг по<br>нормализованному имени<br>+ LLM для несовпадений"]
|
||||||
|
I["⑧ Отчёт о расхождениях<br>━━━━━━━━━━━━━<br>diff-представление<br>подсветка: даты, цены, суммы"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Хранилище"
|
||||||
|
J[(PostgreSQL<br>Документы, Спецификации,<br>События, Промпты)]
|
||||||
|
K[(Доп. хранилище<br>CRM-выгрузки<br>agnostic schema)]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Обратная связь"
|
||||||
|
L[Ручная коррекция<br>экспертом]
|
||||||
|
M[Версионирование<br>исправленных промптов]
|
||||||
|
end
|
||||||
|
|
||||||
|
A --> B --> C --> D --> E --> F --> G
|
||||||
|
G --> H --> I
|
||||||
|
J --- F
|
||||||
|
J --- G
|
||||||
|
K --- H
|
||||||
|
I --> L --> M
|
||||||
|
M -.-> F
|
||||||
|
M -.-> G
|
||||||
|
|
||||||
|
style B fill:#e8f5e9
|
||||||
|
style E fill:#e8f5e9
|
||||||
|
style D fill:#fff3e0
|
||||||
|
style F fill:#fff3e0
|
||||||
|
style G fill:#fff3e0
|
||||||
|
style H fill:#e3f2fd
|
||||||
|
```
|
||||||
|
|
||||||
|
**Зоны ответственности:**
|
||||||
|
|
||||||
|
| Компонент | Где LLM | Где детерминированная логика |
|
||||||
|
|---|---|---|
|
||||||
|
| ① Фильтр мусора | ❌ НЕТ | Ключевые слова + regex по заголовкам (быстро, 0 токенов) |
|
||||||
|
| ② Парсинг | ❌ НЕТ | pdfplumber / python-docx → `elements_json` |
|
||||||
|
| ③ Классификация | ✅ ЛЁГКАЯ LLM | `_smart_extract()` выжимка, `_safe_json_parse()` |
|
||||||
|
| ④ Группировка | ❌ НЕТ | `normalize_number()` + matching по parent_number |
|
||||||
|
| ⑤ Извлечение | ✅ ОСНОВНАЯ LLM | Сборка промпта (`build_prompt`), `_elements_to_text()` |
|
||||||
|
| ⑥ Сравнение | ✅ ОСНОВНАЯ LLM | Event Sourcing (apply ops), `_upsert_spec_current()` |
|
||||||
|
| ⑦ Matching | ✅ LLM для несовпадений | Хеш-матчинг по нормализованному имени для очевидных |
|
||||||
|
| ⑧ Отчёт | ❌ НЕТ | Чистый diff, группировка расхождений по типам |
|
||||||
|
|
||||||
|
**Ключевой принцип:** LLM — только там, где нужна семантика. Всё остальное — быстрый детерминированный код. Это даёт:
|
||||||
|
- Предсказуемость (детерминированное не ломается при смене модели)
|
||||||
|
- Экономию токенов (LLM — дорого и медленно)
|
||||||
|
- Отлаживаемость (можно тестировать unit-тестами без LLM)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Agent-based vs Pipeline
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph "Pipeline (текущий)"
|
||||||
|
P1[Upload] --> P2[Parse] --> P3[Classify] --> P4[Group] --> P5[Compare]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Agent-based (предлагаемый гибрид)"
|
||||||
|
O[Orchestrator Agent]
|
||||||
|
O --> W1[Parse Worker]
|
||||||
|
O --> W2[Classify Worker]
|
||||||
|
O --> W3[Compare Worker]
|
||||||
|
O --> W4[Match Worker]
|
||||||
|
O --> T[Tools: DB, LLM, FileSystem]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
| Критерий | Pipeline | Agent-based |
|
||||||
|
|---|---|---|
|
||||||
|
| **Плюсы** | Предсказуемый порядок, легче отлаживать, меньше токенов, детерминированные шаги не требуют LLM | Гибкость: оркестратор решает **что делать** на основе промежуточных результатов. Может перепланировать при ошибках |
|
||||||
|
| **Минусы** | Жёсткая последовательность. Если шаг упал — либо пропускаем, либо всё стоп. Трудно адаптировать под неожиданные форматы документов | Дороже (каждый шаг оркестратора — LLM-вызов). Сложнее отлаживать. Риск «галлюцинаций» оркестратора |
|
||||||
|
| **Когда** | Когда формат входа **известен**, pipeline стабилен | Когда формат входа **неизвестен**, нужно адаптивное поведение |
|
||||||
|
|
||||||
|
**Мой вердикт: ГИБРИДНЫЙ подход.**
|
||||||
|
|
||||||
|
```
|
||||||
|
Orchestrator (лёгкая LLM, ~500 токенов/вызов)
|
||||||
|
│
|
||||||
|
├── «Это договор?» → Фильтр мусора (детерминирован)
|
||||||
|
├── «Какой тип?» → Classify worker (LLM, уже есть)
|
||||||
|
├── «С чем группировать?» → Grouping (детерминирован)
|
||||||
|
├── «Извлечь спецификацию?» → Extract worker (LLM)
|
||||||
|
└── «Сравнить с CRM?» → Match worker (LLM + хеши)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Почему не pure agents:**
|
||||||
|
- 100+ файлов × 500 токенов оркестратора = 50K+ токенов только на планирование
|
||||||
|
- Для **стандартных** договоров ЦОД pipeline предсказуем — хватит 95% случаев
|
||||||
|
- Оркестратор нужен только для **краевых случаев**: нестандартный формат, ошибка парсинга, конфликт при группировке
|
||||||
|
|
||||||
|
**Инструменты (tools) для агента:**
|
||||||
|
- `parse_document(file_id)` → elements_json
|
||||||
|
- `classify_document(file_id)` → {type, number, date, counterparty}
|
||||||
|
- `extract_spec(contract_id)` → [spec_rows]
|
||||||
|
- `compare_supplement(supp_id, current_spec)` → [ops]
|
||||||
|
- `match_crm_row(spec_row)` → {crm_match, confidence}
|
||||||
|
- `query_db(sql)` → rows (read-only)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 RAG — нужен ли?
|
||||||
|
|
||||||
|
**Кратко: для текущей задачи RAG НЕ НУЖЕН. Хватит контекстного окна.**
|
||||||
|
|
||||||
|
Обоснование:
|
||||||
|
|
||||||
|
| Что | Почему не RAG |
|
||||||
|
|---|---|
|
||||||
|
| **Текст одного допника** | 5-50 KB → влезает в контекстное окно gpt-oss-120b (8K токенов ≈ ~24KB текста) |
|
||||||
|
| **Текущая спецификация** | 10-50 строк × ~200 симв = 10KB → тоже влезает |
|
||||||
|
| **Сравнение договоров** | Не semantic search. Нужно **точное** сопоставление строк, а не «похожие документы» |
|
||||||
|
|
||||||
|
**Когда RAG стал бы нужен (v3+):**
|
||||||
|
- Если бы нужно было искать **похожие прецеденты** в истории (как раньше решали похожие расхождения)
|
||||||
|
- Если бы был корпус из 10 000+ договоров и нужно было искать «как обычно формулируют услугу X»
|
||||||
|
- Для чата: «покажи все договоры где цена стойко-места > 50 000»
|
||||||
|
|
||||||
|
**Что где хранить:**
|
||||||
|
|
||||||
|
| Хранилище | Что |
|
||||||
|
|---|---|
|
||||||
|
| **PostgreSQL (реляционная)** | Документы, `elements_json`, `spec_current`, `spec_events`, контракты, промпты, CRM-выгрузки |
|
||||||
|
| **Файловая система** | Исходные .docx/.pdf (для перепарсивания при смене парсера) |
|
||||||
|
| **Векторная БД** (пока НЕ нужно) | Эмбеддинги названий услуг для семантического matching (альтернатива LLM-matching) |
|
||||||
|
|
||||||
|
**Но:** если модель сменится на что-то с окном 128K+ токенов (Claude, GPT-4o, Gemini), можно будет отправлять **весь договор целиком** + текущую спецификацию. Тогда `_smart_extract` станет не нужен — LLM сама найдёт нужные строки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Блок 2: Обработка 100+ файлов
|
||||||
|
|
||||||
|
### 2.1 Узкие места и масштабирование
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
gantt
|
||||||
|
title Время обработки 100 файлов (текущий pipeline)
|
||||||
|
dateFormat X
|
||||||
|
axisFormat %s
|
||||||
|
|
||||||
|
section Фильтр мусора
|
||||||
|
100 файлов × 10ms :0, 1
|
||||||
|
|
||||||
|
section Парсинг
|
||||||
|
100 файлов × 500ms :1, 50
|
||||||
|
|
||||||
|
section Классификация
|
||||||
|
100 файлов × 2-10s :50, 300
|
||||||
|
|
||||||
|
section Группировка
|
||||||
|
1 вызов × 100ms :300, 300
|
||||||
|
|
||||||
|
section Сравнение (LLM)
|
||||||
|
30 допников × 30s :300, 900
|
||||||
|
```
|
||||||
|
|
||||||
|
**Главное узкое место — КЛАССИФИКАЦИЯ (50-300s для 100 файлов при 4 воркерах).**
|
||||||
|
|
||||||
|
Текущий `ThreadPoolExecutor(max_workers=4)` + `classify_worker.py` subprocess — уже правильное решение. Но для 100+ файлов:
|
||||||
|
|
||||||
|
**Что ещё станет узким местом:**
|
||||||
|
|
||||||
|
| Узкое место | Почему | Решение |
|
||||||
|
|---|---|---|
|
||||||
|
| **Классификация** | 100 файлов × 5s / 4 воркера = 125s | Увеличить `MAX_WORKERS` до 8-10 (но риск троттлинга api.aillm.ru) |
|
||||||
|
| **Сравнение ДС** | 30 допников × 30s последовательно = 900s (15 мин!) | Параллельное сравнение **независимых** групп (разные contract_id — нет гонки) |
|
||||||
|
| **Парсинг docx/pdf** | Java POI через Lucee — медленно для 100 файлов | Перенести парсинг на ВМ (pdfplumber/python-docx, см. ниже) |
|
||||||
|
| **Память процесса** | 100 `elements_json` в памяти БД | Ок — в БД, не в памяти питона |
|
||||||
|
| **api.aillm.ru rate limit** | Бесплатный эндпоинт, неизвестный лимит | Семафор + exponential backoff |
|
||||||
|
|
||||||
|
**Предлагаемые улучшения:**
|
||||||
|
|
||||||
|
1. **Перенос парсинга на ВМ** (убрать зависимость от Lucee/Java):
|
||||||
|
```
|
||||||
|
СЕЙЧАС: JS → convert_server → Lucee parser.cfm (Java POI/PDFBox) → обратно на ВМ
|
||||||
|
ПРЕДЛОЖЕНИЕ: JS → convert_server → services/parse.py (pdfplumber + python-docx)
|
||||||
|
```
|
||||||
|
- python-docx для .docx (чистый Python, без Java)
|
||||||
|
- pdfplumber для .pdf (лучше PDFBox для таблиц)
|
||||||
|
- Убирает latency сетевого вызова Lucee → ВМ
|
||||||
|
|
||||||
|
2. **Асинхронная очередь классификации:**
|
||||||
|
```
|
||||||
|
Upload → Parse → [положить в очередь] → сразу вернуть «файлы загружены»
|
||||||
|
→ background: classify → group
|
||||||
|
```
|
||||||
|
Заказчик не ждёт 125 секунд. Видит прогресс-бар через `/api/batch-progress`.
|
||||||
|
|
||||||
|
3. **Параллельное сравнение групп:**
|
||||||
|
```python
|
||||||
|
# Сейчас: последовательно по всем supps
|
||||||
|
for s in supps: # 30 допников × 30s = 900s
|
||||||
|
compare(s)
|
||||||
|
|
||||||
|
# Предложение: параллельно по НЕЗАВИСИМЫМ группам
|
||||||
|
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||||
|
futures = {pool.submit(compare_group, g): g for g in independent_groups}
|
||||||
|
```
|
||||||
|
Группы с разными `contract_id` независимы — можно сравнивать параллельно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Очередь (RabbitMQ/Redis/Kafka) или PostgreSQL?
|
||||||
|
|
||||||
|
| Критерий | PostgreSQL (текущий) | Redis | RabbitMQ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Простота** | ✅ Уже есть, не надо ставить | Средне | Средне |
|
||||||
|
| **Надёжность** | ✅ ACID, не теряем задачи | ❌ Может потерять при перезапуске | ✅ Persistence |
|
||||||
|
| **Мониторинг** | ✅ SELECT для просмотра очереди | Нужен redis-cli | Нужен management plugin |
|
||||||
|
| **Производительность** | Средне (polling) | ✅ Высокая (pub/sub) | ✅ Высокая |
|
||||||
|
| **Подходит для** | **До 1000 файлов/день** | До 10K/день | До 100K/день |
|
||||||
|
|
||||||
|
**Вердикт: PostgreSQL ДОСТАТОЧНО для текущего масштаба.**
|
||||||
|
|
||||||
|
Для 100+ файлов за раз, несколько раз в неделю — PostgreSQL-очередь через `documents.classify_status = 'pending'` + `classify_worker.py` subprocess — адекватное решение. Не надо усложнять.
|
||||||
|
|
||||||
|
**Когда переходить на RabbitMQ/Redis:**
|
||||||
|
- Если заказчик начнёт загружать 1000+ файлов **ежедневно**
|
||||||
|
- Если появятся **несколько воркеров** на разных машинах
|
||||||
|
- Если нужен **приоритет** (срочные договоры вне очереди)
|
||||||
|
|
||||||
|
**Предлагаемая схема на PostgreSQL (минимальные изменения):**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Добавляем поле для очереди
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_priority INT DEFAULT 0;
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_attempts INT DEFAULT 0;
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_next_attempt TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Воркер забирает задачи с ORDER BY priority, attempt, next_attempt
|
||||||
|
SELECT * FROM documents
|
||||||
|
WHERE classify_status = 'pending'
|
||||||
|
AND (classify_next_attempt IS NULL OR classify_next_attempt <= NOW())
|
||||||
|
ORDER BY classify_priority DESC, classify_attempts ASC
|
||||||
|
LIMIT 10
|
||||||
|
FOR UPDATE SKIP LOCKED; -- конкурентное потребление
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 «Мусорная» фильтрация на раннем этапе
|
||||||
|
|
||||||
|
**Это КРИТИЧЕСКИ важно для 100+ файлов.** Если 50% файлов — счета/акты/платёжки, а мы их парсим и классифицируем — тратим 50% ресурсов впустую.
|
||||||
|
|
||||||
|
**Предлагаемый трёхэтапный фильтр:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Этап 1: Фильтр по имени файла (0ms, детерминирован)
|
||||||
|
├── regex: (сч[её]т|акт|плат[её]ж|УПД|сверк|инвойс|invoice|act|payment)
|
||||||
|
├── сразу помечать doc_type='garbage', НЕ парсить
|
||||||
|
└── точность: ~40% мусора
|
||||||
|
|
||||||
|
Этап 2: Фильтр по первым 2KB текста (после парсинга, 10ms, детерминирован)
|
||||||
|
├── Ключевые слова в заголовке:
|
||||||
|
│ «СЧЕТ-ФАКТУРА», «АКТ СВЕРКИ», «АКТ оказанных услуг»,
|
||||||
|
│ «ПЛАТЁЖНОЕ ПОРУЧЕНИЕ», «УПД», «СЧЕТ НА ОПЛАТУ»
|
||||||
|
├── Если нашли → помечать doc_type='garbage', НЕ классифицировать LLM
|
||||||
|
└── точность: ~55% мусора (суммарно)
|
||||||
|
|
||||||
|
Этап 3: LLM-классификация (оставшиеся, 2-10s)
|
||||||
|
├── Только для файлов, прошедших этапы 1-2
|
||||||
|
└── LLM определяет точный тип: contract/supplement/specification/other
|
||||||
|
```
|
||||||
|
|
||||||
|
**Реализация этапа 2 (в `services/classify.py` перед `_call_llm_classify`):**
|
||||||
|
|
||||||
|
```python
|
||||||
|
GARBAGE_MARKERS = [
|
||||||
|
'СЧЕТ-ФАКТУРА', 'СЧЕТ НА ОПЛАТУ', 'АКТ СВЕРКИ', 'АКТ ОКАЗАННЫХ УСЛУГ',
|
||||||
|
'АКТ ВЫПОЛНЕННЫХ РАБОТ', 'ПЛАТЁЖНОЕ ПОРУЧЕНИЕ', 'УНИВЕРСАЛЬНЫЙ ПЕРЕДАТОЧНЫЙ',
|
||||||
|
'УПД', 'ПЛАТЕЖНОЕ ПОРУЧЕНИЕ'
|
||||||
|
]
|
||||||
|
|
||||||
|
def is_garbage_by_header(text):
|
||||||
|
"""Быстрая проверка — не гонять LLM на мусор."""
|
||||||
|
header = text[:2000].upper()
|
||||||
|
for marker in GARBAGE_MARKERS:
|
||||||
|
if marker in header:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
**Экономия:** при 50% мусора в 100 файлах: вместо 100 LLM-вызовов (500s) → 50 LLM-вызовов (250s). **Экономия 50% времени и токенов.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Блок 3: Сравнение CRM ↔ фискальная система
|
||||||
|
|
||||||
|
### 3.1 Agnostic к источнику — проектирование модуля сравнения
|
||||||
|
|
||||||
|
**Проблема:** мы не знаем формат CRM. Сегодня — одна CRM, завтра — другая система.
|
||||||
|
|
||||||
|
**Решение: Абстрактный интерфейс + адаптеры.**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Источники данных"
|
||||||
|
CRM1[CRM<br>(текущая)]
|
||||||
|
CRM2[Другая система<br>(будущая)]
|
||||||
|
FISC[Фискальная система<br>(из договоров)]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Адаптеры (по одному на источник)"
|
||||||
|
A1[CRM Adapter<br>нормализует поля<br>в канонический формат]
|
||||||
|
A2[Future Adapter]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Каноническая модель строки"
|
||||||
|
CAN[CanonicalRow<br>────────────<br>service_name: str<br>article_code: str<br>price: Decimal<br>qty: Decimal<br>sum: Decimal<br>date_start: Date<br>date_end: Date<br>unit: str<br>source: 'crm' | 'fiscal'<br>source_id: str]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Matcher (agnostic)"
|
||||||
|
MATCH[RowMatcher<br>────────────<br>match_by_hash<br>match_by_name<br>match_by_llm<br>→ MatchResult]
|
||||||
|
end
|
||||||
|
|
||||||
|
CRM1 --> A1 --> CAN
|
||||||
|
CRM2 --> A2 --> CAN
|
||||||
|
FISC --> CAN
|
||||||
|
CAN --> MATCH
|
||||||
|
```
|
||||||
|
|
||||||
|
**Каноническая модель `CanonicalRow`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class CanonicalRow:
|
||||||
|
"""Строка спецификации в каноническом формате (source-agnostic)."""
|
||||||
|
service_name: str # нормализованное название услуги
|
||||||
|
article_code: str | None # артикул (если есть)
|
||||||
|
price: Decimal | None
|
||||||
|
qty: Decimal | None
|
||||||
|
sum: Decimal | None
|
||||||
|
date_start: date | None # КРИТИЧНОЕ ПОЛЕ
|
||||||
|
date_end: date | None
|
||||||
|
unit: str | None # кВт, шт., U, Мбит/с, ...
|
||||||
|
source: str # 'crm' | 'fiscal'
|
||||||
|
source_id: str # ссылка на оригинал (для аудита)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name_hash(self) -> str:
|
||||||
|
"""Нормализованный хеш названия для быстрого matching."""
|
||||||
|
return _hash(normalize_service_name(self.service_name))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Адаптер для CRM (пример):**
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CRMSourceAdapter:
|
||||||
|
"""Адаптер для конкретной CRM. Меняется только этот класс."""
|
||||||
|
|
||||||
|
def extract_rows(self, crm_export_path: str) -> list[CanonicalRow]:
|
||||||
|
"""Читает CSV/JSON/API CRM → список CanonicalRow."""
|
||||||
|
# Специфично для CRM заказчика
|
||||||
|
df = pd.read_csv(crm_export_path, sep=';')
|
||||||
|
rows = []
|
||||||
|
for _, r in df.iterrows():
|
||||||
|
rows.append(CanonicalRow(
|
||||||
|
service_name=r['Наименование'],
|
||||||
|
article_code=r.get('Артикул'),
|
||||||
|
price=Decimal(str(r['Цена'])),
|
||||||
|
qty=Decimal(str(r['Кол-во'])),
|
||||||
|
sum=Decimal(str(r['Сумма'])),
|
||||||
|
date_start=parse_date(r['Дата начала']),
|
||||||
|
date_end=parse_date(r.get('Дата окончания')),
|
||||||
|
unit=r.get('Ед. изм.'),
|
||||||
|
source='crm',
|
||||||
|
source_id=r['ID'],
|
||||||
|
))
|
||||||
|
return rows
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ключевое:** когда завтра появится другая система — пишем **только новый адаптер**. Matcher не меняется.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Matching строк: CRM ↔ фискальная система
|
||||||
|
|
||||||
|
**Трёхуровневый matching (от быстрого к точному):**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[CRM строка] --> B{① Хеш-матчинг<br>по name_hash}
|
||||||
|
B -->|Совпал| D[✓ MATCH (100% confidence)]
|
||||||
|
B -->|Не совпал| C{② Семантический<br>по имени}
|
||||||
|
C -->|Высокая confidence| E[✓ MATCH (80-95% confidence)]
|
||||||
|
C -->|Низкая| F{③ LLM-матчинг}
|
||||||
|
F --> G[✓ MATCH / ✗ NO MATCH<br>+ объяснение]
|
||||||
|
```
|
||||||
|
|
||||||
|
**① Хеш-матчинг (0ms, 0 токенов):**
|
||||||
|
```python
|
||||||
|
def match_by_hash(crm_rows, fiscal_rows):
|
||||||
|
"""Точное совпадение по нормализованному имени."""
|
||||||
|
fiscal_by_hash = {r.name_hash: r for r in fiscal_rows}
|
||||||
|
matched = []
|
||||||
|
unmatched = []
|
||||||
|
for crm_row in crm_rows:
|
||||||
|
if crm_row.name_hash in fiscal_by_hash:
|
||||||
|
matched.append((crm_row, fiscal_by_hash[crm_row.name_hash], 1.0))
|
||||||
|
else:
|
||||||
|
unmatched.append(crm_row)
|
||||||
|
return matched, unmatched
|
||||||
|
```
|
||||||
|
|
||||||
|
**② Семантический matching (Python, без LLM):**
|
||||||
|
```python
|
||||||
|
def match_by_name_similarity(crm_row, fiscal_rows, threshold=0.8):
|
||||||
|
"""Fuzzy matching по названиям услуг."""
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
|
best_score = 0
|
||||||
|
best_match = None
|
||||||
|
for f_row in fiscal_rows:
|
||||||
|
score = SequenceMatcher(None,
|
||||||
|
crm_row.service_name.lower(),
|
||||||
|
f_row.service_name.lower()
|
||||||
|
).ratio()
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_match = f_row
|
||||||
|
|
||||||
|
if best_score >= threshold:
|
||||||
|
return best_match, best_score
|
||||||
|
return None, 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**③ LLM-матчинг (для оставшихся ~10-20% сложных случаев):**
|
||||||
|
```
|
||||||
|
Промпт:
|
||||||
|
«Вот строка из CRM: {crm_row}
|
||||||
|
Вот строки из фискальной системы: {fiscal_rows}
|
||||||
|
Найди соответствие или скажи что соответствия нет.
|
||||||
|
Учитывай: синонимы ("аренда стойки" = "colocation"),
|
||||||
|
объединение/разделение строк,
|
||||||
|
PAYG-услуги без артикулов.»
|
||||||
|
```
|
||||||
|
|
||||||
|
**Почему не только LLM:** 100 строк × 500 токенов = 50K токенов только на matching. А ①+② обрабатывают 80% за 0 токенов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Даты — критичный фокус
|
||||||
|
|
||||||
|
**Почему даты — главный источник расхождений (со слов заказчика):**
|
||||||
|
|
||||||
|
- CRM может иметь `date_start = 01.01.2025`
|
||||||
|
- Фискальная система (договор) может иметь `date_start = 15.01.2025` (дата подписания акта приёмки, а не договора)
|
||||||
|
- Разница в 14 дней → недоплата/переплата за 14 дней × стоимость услуги
|
||||||
|
|
||||||
|
**Стратегия сравнения с акцентом на `date_start`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def compare_dates(crm_row, fiscal_row):
|
||||||
|
"""Сравнение дат — основной фокус."""
|
||||||
|
result = {
|
||||||
|
'matched': True,
|
||||||
|
'date_start_match': True,
|
||||||
|
'date_start_diff_days': 0,
|
||||||
|
'date_start_warning': None,
|
||||||
|
'price_match': True,
|
||||||
|
'sum_match': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сравнение дат
|
||||||
|
if crm_row.date_start and fiscal_row.date_start:
|
||||||
|
diff = (crm_row.date_start - fiscal_row.date_start).days
|
||||||
|
result['date_start_diff_days'] = diff
|
||||||
|
if diff != 0:
|
||||||
|
result['date_start_match'] = False
|
||||||
|
if abs(diff) <= 5:
|
||||||
|
result['date_start_warning'] = 'minor' # возможно округление до месяца
|
||||||
|
elif abs(diff) <= 31:
|
||||||
|
result['date_start_warning'] = 'significant' # расхождение на месяц
|
||||||
|
else:
|
||||||
|
result['date_start_warning'] = 'critical' # серьёзное расхождение
|
||||||
|
|
||||||
|
# Если даты не совпадают, но всё остальное совпадает (имя, цена, количество)
|
||||||
|
if not result['date_start_match'] and result['price_match'] and result['sum_match']:
|
||||||
|
result['likely_cause'] = 'date_input_error' # вероятно ошибка ввода даты
|
||||||
|
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
**Визуализация расхождений (диаграмма Ганта):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Услуга | Янв | Фев | Март | Апр |
|
||||||
|
────────────────────┼───────┼───────┼───────┼───────|
|
||||||
|
CRM: Стойка 10kW |████████████████|
|
||||||
|
Фискал: Стойка 10kW | ████████████████|
|
||||||
|
^^^^— расхождение 15 дней
|
||||||
|
```
|
||||||
|
|
||||||
|
Это можно отрендерить как HTML/CSS бары — наглядно видны сдвиги дат.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 PAYG (суффикс `-m`) — стратегия сопоставления
|
||||||
|
|
||||||
|
**Проблема PAYG:**
|
||||||
|
- PAYG-услуги (pay-as-you-go) — переменное потребление, нет фиксированной цены
|
||||||
|
- В счетах нет кодов артикулов
|
||||||
|
- Например: «IP-адрес IPv4-m» в CRM vs «IP-адрес IPv4» в договоре
|
||||||
|
|
||||||
|
**Стратегия:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def normalize_payg_name(name):
|
||||||
|
"""Убирает суффикс -m для сопоставления PAYG-услуг."""
|
||||||
|
import re
|
||||||
|
# Убираем суффикс -m (с границей слова или концом строки)
|
||||||
|
normalized = re.sub(r'-m(\s|$)', r'\1', name)
|
||||||
|
# Примеры:
|
||||||
|
# «IP-адрес IPv4-m» → «IP-адрес IPv4»
|
||||||
|
# «Канал связи 100Мбит/с-m» → «Канал связи 100Мбит/с»
|
||||||
|
return normalized
|
||||||
|
```
|
||||||
|
|
||||||
|
**Алгоритм для PAYG:**
|
||||||
|
1. При matching по имени — нормализовать **оба** названия (убрать `-m`)
|
||||||
|
2. Если match нашёлся → отметить флагом `payg: true`
|
||||||
|
3. Для PAYG-услуг **не сравнивать суммы** (они переменные), сравнивать только **факт наличия услуги** и **единицу измерения**
|
||||||
|
4. Для PAYG-услуг `date_start` **особенно важен** — PAYG тарифицируется с даты начала
|
||||||
|
|
||||||
|
```python
|
||||||
|
def match_payg(crm_row, fiscal_rows):
|
||||||
|
"""Особая логика для PAYG."""
|
||||||
|
crm_name_normalized = normalize_payg_name(crm_row.service_name)
|
||||||
|
|
||||||
|
for f_row in fiscal_rows:
|
||||||
|
f_name_normalized = normalize_payg_name(f_row.service_name)
|
||||||
|
|
||||||
|
if crm_name_normalized == f_name_normalized:
|
||||||
|
return MatchResult(
|
||||||
|
matched=True,
|
||||||
|
payg=True,
|
||||||
|
compare_sum=False, # суммы не сравниваем для PAYG
|
||||||
|
compare_date_start=True, # даты КРИТИЧНЫ
|
||||||
|
note=f'PAYG: {crm_row.service_name} ↔ {f_row.service_name}',
|
||||||
|
)
|
||||||
|
|
||||||
|
return MatchResult(matched=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Блок 4: Итеративность и неопределённость
|
||||||
|
|
||||||
|
### 4.1 Менять промпты/модели/подходы без переписывания кода
|
||||||
|
|
||||||
|
**Что уже есть (✅ хорошо):**
|
||||||
|
- Промпты в БД с версионированием (`prompts` таблица + `prompt.cfm`/`db/prompts.py`)
|
||||||
|
- Редактор промптов с историей версий
|
||||||
|
- Активный промпт выбирается из БД, не хардкод
|
||||||
|
|
||||||
|
**Что предлагаю добавить:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# config.py — ЕДИНСТВЕННОЕ место для конфигурации LLM
|
||||||
|
@dataclass
|
||||||
|
class LLMConfig:
|
||||||
|
"""Меняется без правки кода — через БД или env."""
|
||||||
|
model: str = os.environ.get("LLM_MODEL", "gpt-oss-120b")
|
||||||
|
url: str = os.environ.get("LLM_URL", "https://api.aillm.ru/v1/chat/completions")
|
||||||
|
max_tokens: int = int(os.environ.get("LLM_MAX_TOKENS", "8000"))
|
||||||
|
temperature: float = float(os.environ.get("LLM_TEMPERATURE", "0.1"))
|
||||||
|
timeout: int = int(os.environ.get("LLM_TIMEOUT", "120"))
|
||||||
|
|
||||||
|
# Разные модели для разных задач
|
||||||
|
classify_model: str = os.environ.get("LLM_CLASSIFY_MODEL", model) # полегче
|
||||||
|
extract_model: str = os.environ.get("LLM_EXTRACT_MODEL", model) # основная
|
||||||
|
match_model: str = os.environ.get("LLM_MATCH_MODEL", model) # для сверки
|
||||||
|
```
|
||||||
|
|
||||||
|
**Принцип: всё что может поменяться — в БД или env. Код — только движок.**
|
||||||
|
|
||||||
|
| Что меняется | Где менять | Без правки кода? |
|
||||||
|
|---|---|---|
|
||||||
|
| Промпт | БД `prompts` → activate | ✅ Да |
|
||||||
|
| Модель LLM | env `LLM_MODEL` | ✅ Да |
|
||||||
|
| Температура | env `LLM_TEMPERATURE` | ✅ Да |
|
||||||
|
| URL API | env `LLM_URL` | ✅ Да |
|
||||||
|
| Garbage-маркеры | `garbage_markers.json` в БД или файле | ✅ Да |
|
||||||
|
| Стратегия matching | `matching_rules` в БД | ✅ Да (если сделать rules engine) |
|
||||||
|
| Порядок pipeline | ❌ Пока хардкод | 🔧 Можно сделать DAG в БД |
|
||||||
|
|
||||||
|
**Предложение: Pipeline as DAG в БД:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE pipeline_steps (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL, -- 'filter_garbage', 'parse', 'classify', 'extract', ...
|
||||||
|
handler TEXT NOT NULL, -- 'services.classify:classify_batch'
|
||||||
|
depends_on INT[] DEFAULT '{}', -- какие шаги должны быть завершены
|
||||||
|
config JSONB DEFAULT '{}', -- параметры шага
|
||||||
|
enabled BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Меняя записи в этой таблице, можно переставлять шаги или добавлять новые **без правки кода**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 MVP-границы: v1, v2, v3
|
||||||
|
|
||||||
|
```
|
||||||
|
v1 (MVP) — БАЗОВАЯ ФУНКЦИЯ
|
||||||
|
├── ✅ Загрузка 100+ файлов (из локали/файловой шары)
|
||||||
|
├── ✅ Фильтр мусора (этапы 1-2, детерминированные)
|
||||||
|
├── ✅ Парсинг docx/pdf на ВМ (убрать зависимость от Lucee)
|
||||||
|
├── ✅ Классификация (LLM, параллельно)
|
||||||
|
├── ✅ Группировка по контрагентам
|
||||||
|
├── ✅ Извлечение спецификации (LLM)
|
||||||
|
├── ✅ Сравнение ДС (LLM + Event Sourcing)
|
||||||
|
├── ✅ Выгрузка результата (CSV/JSON)
|
||||||
|
├── ❌ БЕЗ сверки с CRM (только извлечение из договоров)
|
||||||
|
├── ❌ БЕЗ красивого UI (минимальный интерфейс для отладки)
|
||||||
|
└── Домен: contracts.kube5s.ru
|
||||||
|
|
||||||
|
v2 — СВЕРКА
|
||||||
|
├── ✅ Адаптер CRM (первый источник)
|
||||||
|
├── ✅ Matching CRM ↔ фискальная (трёхуровневый)
|
||||||
|
├── ✅ Отчёт о расхождениях (diff)
|
||||||
|
├── ✅ Подсветка дат
|
||||||
|
├── ✅ PAYG-обработка
|
||||||
|
├── ✅ Ручная коррекция экспертом
|
||||||
|
└── Домен: check.kube5s.ru
|
||||||
|
|
||||||
|
v3 — ЮЗАБЕЛЬНОСТЬ
|
||||||
|
├── ✅ Красивый UI (две панели, отчёт)
|
||||||
|
├── ✅ Векторная БД для семантического поиска
|
||||||
|
├── ✅ Чат (Q&A по всем договорам)
|
||||||
|
├── ✅ Автообучение на коррекциях (few-shot из истории)
|
||||||
|
├── ✅ Экспорт в Excel
|
||||||
|
└── Домен: contracts.kube5s.ru (единый)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Почему сверка с CRM — это v2, а не v1:**
|
||||||
|
- Заказчик сам говорит: «сейчас и с подходом всё неясно»
|
||||||
|
- Сначала надо доказать что LLM **вообще** может точно извлечь спецификацию из 100+ договоров
|
||||||
|
- Потом, имея эталонные данные, строить сверку
|
||||||
|
- Это снижает риск: не строим сложный matching для неподтверждённого качества извлечения
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Цикл обратной связи
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A[LLM извлекает спецификацию] --> B[Результат показан эксперту]
|
||||||
|
B --> C{Эксперт: верно?}
|
||||||
|
C -->|✅ Да| D[Сохраняем как<br>положительный пример]
|
||||||
|
C -->|❌ Нет| E[Эксперт исправляет]
|
||||||
|
E --> F[Сохраняем пару<br>❌ было → ✅ стало]
|
||||||
|
F --> G[Аналитика ошибок<br>какие типы ошибок частые?]
|
||||||
|
G --> H{Можно исправить<br>промптом?}
|
||||||
|
H -->|Да| I[Правим промпт<br>новая версия]
|
||||||
|
H -->|Нет| J[Меняем подход<br>алгоритм / модель]
|
||||||
|
I --> K[Few-shot примеры<br>в промпт]
|
||||||
|
D --> K
|
||||||
|
K --> A
|
||||||
|
|
||||||
|
style C fill:#fff3e0
|
||||||
|
style G fill:#e3f2fd
|
||||||
|
```
|
||||||
|
|
||||||
|
**Конкретная реализация:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Таблица коррекций эксперта
|
||||||
|
CREATE TABLE expert_corrections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id UUID REFERENCES spec_events(id), -- какое событие LLM
|
||||||
|
corrected_values JSONB NOT NULL, -- что исправил эксперт
|
||||||
|
correction_type TEXT, -- 'name_fix', 'date_fix', 'price_fix', 'missing_row', 'extra_row'
|
||||||
|
expert_comment TEXT,
|
||||||
|
used_in_prompt BOOLEAN DEFAULT false, -- включено в few-shot?
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Как это использовать:**
|
||||||
|
|
||||||
|
1. **Быстрый цикл (часы):** эксперт исправил → сохранили в `expert_corrections` → аналитика показывает «5 из 10 ошибок — даты» → правим промпт (добавляем акцент на даты) → активируем новую версию → лучше.
|
||||||
|
|
||||||
|
2. **Few-shot обучение (дни):** накопили 20+ коррекций одного типа → добавляем в промпт как few-shot примеры:
|
||||||
|
```
|
||||||
|
ПРИМЕРЫ ОШИБОК (НЕ ПОВТОРЯЙ):
|
||||||
|
❌ Было: "Аренда стойко-места" с date_start: null
|
||||||
|
✅ Верно: "Аренда стойко-места" с date_start: "2025-01-01" (дата в преамбуле договора)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **A/B тестирование промптов (недели):** запускаем старый и новый промпт на одном документе → сравниваем результаты → выбираем лучший.
|
||||||
|
|
||||||
|
**Ключевое:** не пытаемся «обучить модель» (это не наша модель, gpt-oss-120b — API). Вместо этого:
|
||||||
|
- Улучшаем промпты
|
||||||
|
- Добавляем few-shot примеры
|
||||||
|
- Меняем подход к извлечению
|
||||||
|
- **Фиксируем все решения в БД** — чтобы через месяц понять что работало, а что нет
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итоговая архитектура (сводка)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "v1: Извлечение (MVP)"
|
||||||
|
U1[100+ файлов] --> F1[Фильтр мусора ⚡0ms]
|
||||||
|
F1 --> P1[Парсинг Python ⚡500ms]
|
||||||
|
P1 --> C1[Классификация LLM 🔥2-10s]
|
||||||
|
C1 --> G1[Группировка Python ⚡100ms]
|
||||||
|
G1 --> E1[Извлечение LLM 🔥30s]
|
||||||
|
E1 --> S1[(spec_current)]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "v2: Сверка"
|
||||||
|
CRM[CRM-выгрузка] --> AD[CRM Adapter]
|
||||||
|
S1 --> MC[Matcher ⚡хеш → fuzzy → LLM]
|
||||||
|
AD --> MC
|
||||||
|
MC --> RPT[Отчёт о расхождениях]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Обратная связь"
|
||||||
|
RPT --> EXP[Эксперт]
|
||||||
|
EXP --> CORR[expert_corrections]
|
||||||
|
CORR --> PROMPT[Улучшение промптов]
|
||||||
|
PROMPT -.-> E1
|
||||||
|
end
|
||||||
|
|
||||||
|
style F1 fill:#e8f5e9
|
||||||
|
style G1 fill:#e8f5e9
|
||||||
|
style C1 fill:#fff3e0
|
||||||
|
style E1 fill:#fff3e0
|
||||||
|
style MC fill:#e3f2fd
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Практические рекомендации для DeepSeek V4 Pro
|
||||||
|
|
||||||
|
### Что кодить СЕЙЧАС (Фаза 0: изоляция)
|
||||||
|
|
||||||
|
Как описано в `opus-plan-review-2026-06-27.md`, план Opus из 6 фаз — правильный вектор. Но предлагаю **упростить Фазу 0**:
|
||||||
|
|
||||||
|
**Ф0. Предпосылки (1-2 часа):**
|
||||||
|
1. `pg_dump --schema-only` с продакшена → `schema.sql`
|
||||||
|
2. Создать новую БД `contracts_flask` на ВМ, применить `schema.sql`
|
||||||
|
3. DNS `check.kube5s.ru` → уже есть ✅
|
||||||
|
4. Пустой репо `contracts-flask` → уже есть ✅
|
||||||
|
5. `LLM_KEY` → должен быть в `.env` на ВМ
|
||||||
|
|
||||||
|
**Ф1. Бэкенд на ВМ (основная работа):**
|
||||||
|
- `~/contracts-flask/` — копия `deploy/` (db/, services/, llm_prompt.py, convert_server.py)
|
||||||
|
- ИСПРАВИТЬ `llm_prompt.py` — убрать зависимость от Lucee (`_fetch_prompt()` → `db.prompts.get_active()`)
|
||||||
|
- ИСПРАВИТЬ `services/llm.py` — взять версию из репы (свежее)
|
||||||
|
- Порт 8777, БД `contracts_flask`, systemd-юнит `contracts-flask.service`
|
||||||
|
|
||||||
|
**Ф2-Ф3. Nginx + JS:**
|
||||||
|
- Nginx: `check.kube5s.ru` → :8777
|
||||||
|
- JS: скопировать 6 файлов, поменять `VM_API='https://check.kube5s.ru'`
|
||||||
|
|
||||||
|
**Ф4+ отложить** — морда на managed Flask не нужна для v1 MVP. Используем текущий index.cfm как есть, или минимальный HTML на ВМ.
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
1. **Загрузка** — файлы принимаются через веб-интерфейс. Поддерживаются .docx, .doc (старый Word), .pdf, а также .zip с несколькими файлами.
|
1. **Загрузка** — файлы принимаются через веб-интерфейс. Поддерживаются .docx, .doc (старый Word), .pdf, а также .zip с несколькими файлами.
|
||||||
|
|
||||||
2. **Парсинг** — каждый файл автоматически разбирается: извлекаются таблицы и текст. Используется Apache POI (Java) для Word-документов и PDFBox для PDF. Результат — структурированный JSON (elements_json) и текстовое представление.
|
2. **Парсинг** — каждый файл автоматически разбирается на ВМ: извлекаются таблицы и текст. Используется pdfplumber для PDF (с сохранением структуры таблиц) и python-docx для Word. Результат — структурированный JSON (elements_json) и текстовое представление.
|
||||||
|
|
||||||
3. **Порядок** — файлы можно переставить стрелками ↕. Первый в списке считается базовым договором, остальные — дополнительные документы к нему.
|
3. **Порядок** — файлы можно переставить стрелками ↕. Первый в списке считается базовым договором, остальные — дополнительные документы к нему.
|
||||||
|
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
│
|
│
|
||||||
├── загрузка файлов ──→ VM (Python, convert_server.py)
|
├── загрузка файлов ──→ VM (Python, convert_server.py)
|
||||||
│ │
|
│ │
|
||||||
│ ├── /convert-doc ──→ Lucee (parser.cfm)
|
│ ├── /upload ──→ parse.py (pdfplumber + python-docx)
|
||||||
│ ├── /process-v2 ───→ Lucee (apply_events.cfm)
|
│ ├── /llm-ops ──→ LLM (api.aillm.ru, gpt-oss-120b)
|
||||||
│ └── LLM (api.aillm.ru, gpt-oss-120b)
|
│ └── /process-v2 ──→ Lucee (apply_events.cfm)
|
||||||
│
|
│
|
||||||
└── API ──→ Lucee (CFML на k8s)
|
└── API ──→ Lucee (CFML на k8s)
|
||||||
│
|
│
|
||||||
@@ -44,8 +44,8 @@
|
|||||||
|-----------|-----|------------|
|
|-----------|-----|------------|
|
||||||
| Веб-интерфейс | Lucee 6.0 (k8s) | CFML + JavaScript |
|
| Веб-интерфейс | Lucee 6.0 (k8s) | CFML + JavaScript |
|
||||||
| База данных | Внутренний PostgreSQL 15 | JSONB, UUID, advisory locks |
|
| База данных | Внутренний PostgreSQL 15 | JSONB, UUID, advisory locks |
|
||||||
| Парсинг документов | Lucee | Apache POI (HWPF/XWPF), PDFBox |
|
| Парсинг документов | ВМ (5.172.178.213) | pdfplumber (PDF) + python-docx (DOCX) |
|
||||||
| LLM-анализ | Внешняя VM (5.172.178.213) | Python 3, httpx, SSE-стриминг |
|
| LLM-анализ | ВМ (5.172.178.213) | Python 3, httpx, SSE-стриминг |
|
||||||
| Модель | api.aillm.ru | gpt-oss-120b (бесплатно, 8000 токенов) |
|
| Модель | api.aillm.ru | gpt-oss-120b (бесплатно, 8000 токенов) |
|
||||||
|
|
||||||
## Event Sourcing
|
## Event Sourcing
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
- **Backend**: Lucee 6.0 (CFML) на Kubernetes
|
- **Backend**: Lucee 6.0 (CFML) на Kubernetes
|
||||||
- **База**: PostgreSQL 15 (JSONB, UUID, window functions)
|
- **База**: PostgreSQL 15 (JSONB, UUID, window functions)
|
||||||
- **Парсинг**: Apache POI (Java, встроен в Lucee)
|
- **Парсинг**: pdfplumber (PDF) + python-docx (DOCX) на ВМ
|
||||||
- **LLM-прокси**: Python 3 + httpx + threading (SSE)
|
- **LLM-прокси**: Python 3 + httpx + threading (SSE)
|
||||||
- **Модель**: gpt-oss-120b (OpenAI-совместимый API)
|
- **Модель**: gpt-oss-120b (OpenAI-совместимый API)
|
||||||
- **Фронтенд**: ванильный JS + Lucide иконки
|
- **Фронтенд**: ванильный JS + Lucide иконки
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# Устройство ВМ contracts.kube5s.ru
|
||||||
|
|
||||||
|
> **ВАЖНО**: Это актуальная документация. Старый `architecture.md` описывает мёртвый Flask `contracts-app/site/` — к ВМ отношения не имеет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Где что лежит
|
||||||
|
|
||||||
|
```
|
||||||
|
РЕПОЗИТОРИЙ (локально) ВМ (5.172.178.213)
|
||||||
|
/home/naeel/nubes/contracts/ /home/naeel/contracts/
|
||||||
|
│ │
|
||||||
|
├── contractor/ │
|
||||||
|
│ └── deploy/ ◀─── sync.sh ───▶ ВСЁ содержимое deploy/
|
||||||
|
│ ├── app.js ├── app.js
|
||||||
|
│ ├── app_utils.js ├── app_utils.js
|
||||||
|
│ ├── compare.js ├── compare.js
|
||||||
|
│ ├── files.js ├── files.js
|
||||||
|
│ ├── groups.js ├── groups.js
|
||||||
|
│ ├── state.js ├── state.js
|
||||||
|
│ ├── tests.js ├── (нет на ВМ)
|
||||||
|
│ ├── classify_worker.py ├── classify_worker.py
|
||||||
|
│ ├── convert_doc.py ├── convert_doc.py
|
||||||
|
│ ├── convert_server.py ◀── ГЛАВНЫЙ ──▶ convert_server.py (порт 8766)
|
||||||
|
│ ├── llm_prompt.py ├── llm_prompt.py
|
||||||
|
│ ├── nginx-contracts.conf ├── nginx-contracts.conf
|
||||||
|
│ ├── db/ ├── db/
|
||||||
|
│ │ ├── __init__.py │ ├── __init__.py
|
||||||
|
│ │ ├── connection.py │ ├── connection.py
|
||||||
|
│ │ ├── contracts.py │ ├── contracts.py
|
||||||
|
│ │ ├── documents.py │ ├── documents.py
|
||||||
|
│ │ ├── prompts.py │ ├── prompts.py
|
||||||
|
│ │ ├── spec_current.py │ ├── spec_current.py
|
||||||
|
│ │ ├── spec_events.py │ ├── spec_events.py
|
||||||
|
│ │ └── supplements.py │ └── supplements.py
|
||||||
|
│ ├── services/ ├── services/
|
||||||
|
│ │ ├── __init__.py │ ├── __init__.py
|
||||||
|
│ │ ├── classify.py │ ├── classify.py
|
||||||
|
│ │ ├── grouping.py │ ├── grouping.py
|
||||||
|
│ │ ├── llm.py │ ├── llm.py
|
||||||
|
│ │ ├── parse.py │ ├── parse.py
|
||||||
|
│ │ ├── process.py │ ├── process.py
|
||||||
|
│ │ ├── unzip.py │ ├── unzip.py
|
||||||
|
│ │ └── upload.py │ └── upload.py
|
||||||
|
│ └── sync.sh │
|
||||||
|
│ ├── site/ ← Flask UI (НЕ из deploy)
|
||||||
|
│ ├── .env ← VM-специфично
|
||||||
|
│ ├── gunicorn.conf.py
|
||||||
|
│ ├── start.sh
|
||||||
|
│ ├── logs/
|
||||||
|
│ │
|
||||||
|
│ ├── classify.py ← ⛔ МУСОР (не юзается)
|
||||||
|
│ ├── grouping.py ← ⛔ МУСОР (не юзается)
|
||||||
|
│ ├── prompts.py ← ⛔ МУСОР (не юзается)
|
||||||
|
│ └── unzip.py ← ⛔ МУСОР (не юзается)
|
||||||
|
│
|
||||||
|
├── contracts-app/ ← ⛔ МЁРТВЫЙ Flask v1, к ВМ отношения НЕ ИМЕЕТ
|
||||||
|
│
|
||||||
|
├── contractor/ ← ColdFusion/Lucee (старый бекенд, не на ВМ)
|
||||||
|
│
|
||||||
|
└── contracts-vm/ ← ?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⛔ КОРНЕВЫЕ .py НА ВМ — МУСОР
|
||||||
|
|
||||||
|
На ВМ в `~/contracts/` лежат файлы:
|
||||||
|
- `classify.py`
|
||||||
|
- `grouping.py`
|
||||||
|
- `prompts.py`
|
||||||
|
- `unzip.py`
|
||||||
|
|
||||||
|
**Эти файлы НЕ ИМПОРТИРУЮТСЯ и НЕ ИСПОЛЬЗУЮТСЯ.** Они остались от старой плоской структуры.
|
||||||
|
|
||||||
|
Реально используемые версии лежат в подпапках:
|
||||||
|
- `services/classify.py` ✅
|
||||||
|
- `services/grouping.py` ✅
|
||||||
|
- `db/prompts.py` ✅
|
||||||
|
- `services/unzip.py` ✅
|
||||||
|
|
||||||
|
`convert_server.py` импортирует ТОЛЬКО из `db/` и `services/`:
|
||||||
|
```python
|
||||||
|
from db import prompts as db_prompts
|
||||||
|
from db.connection import DB_CONFIG, execute
|
||||||
|
from db import supplements as db_supplements
|
||||||
|
from db import documents as db_documents
|
||||||
|
from db import spec_current as db_spec_current
|
||||||
|
from services.upload import handle_upload
|
||||||
|
from services.unzip import handle_unzip
|
||||||
|
from services.process import run_pipeline
|
||||||
|
from llm_prompt import build_prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура на ВМ
|
||||||
|
|
||||||
|
```
|
||||||
|
Браузер
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Nginx :443 (nginx-contracts.conf)
|
||||||
|
│
|
||||||
|
├── / → 127.0.0.1:5001 (Flask, gunicorn)
|
||||||
|
│ site/app.py — отдаёт HTML (index.html, upload.html)
|
||||||
|
│ Статика: templates/, static/
|
||||||
|
│
|
||||||
|
└── /upload, /convert-doc, /unzip-upload,
|
||||||
|
/llm-ops, /process-v2, /parse-pdf, /static/
|
||||||
|
→ 127.0.0.1:8766 (convert_server.py)
|
||||||
|
│
|
||||||
|
├── db/ — PostgreSQL через psycopg2
|
||||||
|
├── services/ — бизнес-логика
|
||||||
|
└── llm_prompt.py — сборка промптов
|
||||||
|
```
|
||||||
|
|
||||||
|
### Три процесса
|
||||||
|
|
||||||
|
| Процесс | Порт | Что | Запуск |
|
||||||
|
|---|---|---|---|
|
||||||
|
| gunicorn | 5001 | Flask UI | `start.sh` |
|
||||||
|
| convert_server.py | 8766 | **Главный API-сервер** | `sync.sh` (после деплоя) |
|
||||||
|
| nginx | 443 | Прокси + SSL | systemd |
|
||||||
|
|
||||||
|
### JS-файлы — клиентские
|
||||||
|
|
||||||
|
`app.js`, `files.js`, `groups.js`, `state.js`, `compare.js`, `app_utils.js` — это **клиентский JavaScript**. Они не запускаются на ВМ как процесс. Их отдаёт Flask через HTML-шаблоны, и они выполняются в браузере.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Деплой (sync.sh)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запускать из contractor/
|
||||||
|
bash deploy/sync.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Сейчас деплоит только 2 файла:**
|
||||||
|
- `convert_server.py`
|
||||||
|
- `convert_doc.py`
|
||||||
|
|
||||||
|
**Должен деплоить ВСЁ из `deploy/`** (кроме `sync.sh` и `__pycache__`).
|
||||||
|
|
||||||
|
После деплоя — `pkill -f convert_server.py && nohup python3 convert_server.py &`
|
||||||
|
|
||||||
|
### Что НЕ деплоить
|
||||||
|
|
||||||
|
- `site/` — Flask UI, живёт своей жизнью
|
||||||
|
- `.env` — переменные окружения ВМ
|
||||||
|
- `gunicorn.conf.py`, `start.sh` — конфиги ВМ
|
||||||
|
- `logs/` — рантайм
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как проверять соответствие
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# md5 всех файлов в db/ и services/ на ВМ
|
||||||
|
ssh naeel@5.172.178.213 'md5sum ~/contracts/db/*.py ~/contracts/services/*.py'
|
||||||
|
|
||||||
|
# Сравнить с локальными
|
||||||
|
md5sum contractor/deploy/db/*.py contractor/deploy/services/*.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Корневые `classify.py`, `grouping.py`, `prompts.py`, `unzip.py` — **НЕ проверять**, это мусор.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Версия
|
||||||
|
|
||||||
|
Актуально на 2026-06-27. При изменениях — обновлять.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Teach flow VM smoke tests
|
||||||
|
|
||||||
|
Дата: 26.06.2026 | Проверка isolated teaching flow на VM после внедрения.
|
||||||
|
|
||||||
|
## Что проверяли
|
||||||
|
|
||||||
|
Проверялся Flask-слой VM через test client с подменой DB-слоя, чтобы подтвердить поведение новых endpoints без риска для боевой БД.
|
||||||
|
|
||||||
|
### Проверенные маршруты
|
||||||
|
|
||||||
|
- `GET /teach`
|
||||||
|
- `GET /teach/api/meta`
|
||||||
|
- `GET /teach/api/contracts`
|
||||||
|
- `GET /teach/api/context?supplement_id=...`
|
||||||
|
- `POST /teach/api/feedback`
|
||||||
|
- `GET /teach/api/feedback?contract_id=...&supplement_id=...`
|
||||||
|
|
||||||
|
## Результат smoke-test
|
||||||
|
|
||||||
|
Все маршруты вернули `200 OK` в mocked окружении.
|
||||||
|
|
||||||
|
### `/teach`
|
||||||
|
|
||||||
|
- Страница отдала HTML с заголовком `Сверка договоров — обучение`.
|
||||||
|
|
||||||
|
### `/teach/api/meta`
|
||||||
|
|
||||||
|
Вернуло дефолтные метаданные:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"prompt_version": "vm-teach-v1",
|
||||||
|
"model_name": "gpt-oss-120b"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/teach/api/contracts`
|
||||||
|
|
||||||
|
Вернуло список договоров и допников в ожидаемой структуре:
|
||||||
|
- `contract_id`
|
||||||
|
- `contract_number`
|
||||||
|
- `client`
|
||||||
|
- `date_signed`
|
||||||
|
- `supplements[]`
|
||||||
|
|
||||||
|
### `/teach/api/context`
|
||||||
|
|
||||||
|
Вернуло:
|
||||||
|
- `supplement`
|
||||||
|
- `current_rows`
|
||||||
|
- `previous_rows`
|
||||||
|
|
||||||
|
### `POST /teach/api/feedback`
|
||||||
|
|
||||||
|
Проверена запись feedback со значениями:
|
||||||
|
- `scope = row`
|
||||||
|
- `verdict = error`
|
||||||
|
- `error_type = wrong_price`
|
||||||
|
- `field = price`
|
||||||
|
- `llm_value` и `correct_value` как JSON
|
||||||
|
- `prompt_version = vm-teach-v1`
|
||||||
|
- `model_name = gpt-oss-120b`
|
||||||
|
- `doc_mode = amendment`
|
||||||
|
|
||||||
|
Запись успешно ушла в mock cursor и commit был вызван.
|
||||||
|
|
||||||
|
### `GET /teach/api/feedback`
|
||||||
|
|
||||||
|
Возвратил сохранённую запись в читаемом JSON виде.
|
||||||
|
|
||||||
|
## Что всплыло по окружению
|
||||||
|
|
||||||
|
Во время проверки не хватало runtime-зависимостей в VM-venv:
|
||||||
|
- `python-dotenv`
|
||||||
|
- `Flask`
|
||||||
|
- `psycopg2-binary`
|
||||||
|
- `httpx`
|
||||||
|
- `pdfplumber`
|
||||||
|
- `python-docx`
|
||||||
|
- `redis`
|
||||||
|
|
||||||
|
Эти пакеты были установлены в VM-venv, после чего smoke-test прошёл.
|
||||||
|
|
||||||
|
## Вывод
|
||||||
|
|
||||||
|
Isolated teaching flow на VM не только компилируется, но и проходит mocked smoke-test по основным endpoint'ам:
|
||||||
|
- чтение метаданных,
|
||||||
|
- загрузка списка договоров,
|
||||||
|
- загрузка контекста допника,
|
||||||
|
- запись и чтение feedback.
|
||||||
|
|
||||||
|
Старый Lucee-frontend и основной compare pipeline при этом не затрагивались.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Feature: isolated teaching flow on VM
|
||||||
|
|
||||||
|
Дата: 26.06.2026 | Переход от обсуждения идеи feedback-learning к реальному isolated-flow на VM.
|
||||||
|
|
||||||
|
## Что решили
|
||||||
|
|
||||||
|
- Lucee-mordа не трогаем.
|
||||||
|
- Новая страница обучения живёт на VM по прямому URL.
|
||||||
|
- Рабочий compare pipeline не меняем.
|
||||||
|
- Feedback пишется только в отдельную таблицу `feedback`.
|
||||||
|
- Для аналитики сохраняем `prompt_version` и `model_name`.
|
||||||
|
- Значения по умолчанию берём из VM metadata endpoint / env, а не из Lucee.
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
### 1. Изолированный Flask blueprint
|
||||||
|
|
||||||
|
Добавлен новый blueprint `teach_bp` в VM-слой:
|
||||||
|
- `/teach` — отдельная страница обучения.
|
||||||
|
- `/teach/api/meta` — дефолтные метаданные для страницы.
|
||||||
|
- `/teach/api/contracts` — список договоров и допников.
|
||||||
|
- `/teach/api/context` — текущие строки спецификации и предыдущие строки для выбранного допника.
|
||||||
|
- `/teach/api/feedback` — чтение и запись feedback.
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
- [contracts-app/site/teach.py](../../contracts-app/site/teach.py)
|
||||||
|
- [contracts-app/site/app.py](../../contracts-app/site/app.py)
|
||||||
|
|
||||||
|
### 2. Отдельная таблица feedback
|
||||||
|
|
||||||
|
Добавлена таблица `feedback` в DDL приложения. В ней хранятся:
|
||||||
|
- `contract_id`
|
||||||
|
- `supplement_id`
|
||||||
|
- `event_seq`
|
||||||
|
- `scope`
|
||||||
|
- `verdict`
|
||||||
|
- `error_type`
|
||||||
|
- `field`
|
||||||
|
- `service_name`
|
||||||
|
- `llm_value`
|
||||||
|
- `correct_value`
|
||||||
|
- `prompt_version`
|
||||||
|
- `model_name`
|
||||||
|
- `doc_mode`
|
||||||
|
- `comment`
|
||||||
|
|
||||||
|
Файл:
|
||||||
|
- [contracts-app/site/schema.py](../../contracts-app/site/schema.py)
|
||||||
|
|
||||||
|
### 3. Teach UI
|
||||||
|
|
||||||
|
Добавлена отдельная страница:
|
||||||
|
- список договоров и допников слева;
|
||||||
|
- таблица строк спецификации справа;
|
||||||
|
- кнопка `⚠` у строки для замечания;
|
||||||
|
- кнопка `✓ Всё верно`;
|
||||||
|
- кнопка `➕ Пропущена позиция`;
|
||||||
|
- отдельная форма для комментария и correct value;
|
||||||
|
- автоматический bootstrap metadata через `/teach/api/meta`.
|
||||||
|
|
||||||
|
Файл:
|
||||||
|
- [contracts-app/site/templates/teach.html](../../contracts-app/site/templates/teach.html)
|
||||||
|
|
||||||
|
### 4. Метаданные обучения
|
||||||
|
|
||||||
|
Сделан безопасный fallback:
|
||||||
|
- `prompt_version` по умолчанию: `vm-teach-v1`
|
||||||
|
- `model_name` по умолчанию: `gpt-oss-120b`
|
||||||
|
- можно переопределить через URL: `?prompt_version=...&model=...`
|
||||||
|
- можно переопределить через env:
|
||||||
|
- `TEACH_PROMPT_VERSION`
|
||||||
|
- `TEACH_MODEL_NAME`
|
||||||
|
|
||||||
|
### 5. Миграции без риска
|
||||||
|
|
||||||
|
Чтобы не ломать уже существующую БД, добавлены:
|
||||||
|
- `ALTER TABLE feedback ADD COLUMN IF NOT EXISTS prompt_version TEXT`
|
||||||
|
- `ALTER TABLE feedback ADD COLUMN IF NOT EXISTS model_name TEXT`
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
Синтаксис VM-файлов проверен через `python3 -m py_compile`:
|
||||||
|
- `app.py`
|
||||||
|
- `teach.py`
|
||||||
|
- `schema.py`
|
||||||
|
- `db.py`
|
||||||
|
- `api.py`
|
||||||
|
- `upload.py`
|
||||||
|
- `extractor.py`
|
||||||
|
- `differ.py`
|
||||||
|
- `test_routes.py`
|
||||||
|
|
||||||
|
Ошибок нет.
|
||||||
|
|
||||||
|
## Что важно не перепутать
|
||||||
|
|
||||||
|
- Старая Lucee-морда не нужна для этой фичи.
|
||||||
|
- Обучение открывается по прямому URL на VM.
|
||||||
|
- Никакого вмешательства в compare/upload pipeline нет.
|
||||||
|
- `feedback` — отдельная аналитическая шина, не часть боевого event sourcing.
|
||||||
|
|
||||||
|
## Что ещё осталось
|
||||||
|
|
||||||
|
- Подключить реальный smoke-test к VM endpoint'ам и проверить insert/select на живой БД.
|
||||||
|
- Если нужно, сделать отдельный read-only список накопленного feedback для агента.
|
||||||
|
- При желании можно later подтянуть prompt/model metadata не из URL/env, а из отдельной VM-конфигурации.
|
||||||
|
|
||||||
|
## Ключевые файлы
|
||||||
|
|
||||||
|
- [contracts-app/site/app.py](../../contracts-app/site/app.py)
|
||||||
|
- [contracts-app/site/schema.py](../../contracts-app/site/schema.py)
|
||||||
|
- [contracts-app/site/teach.py](../../contracts-app/site/teach.py)
|
||||||
|
- [contracts-app/site/templates/teach.html](../../contracts-app/site/templates/teach.html)
|
||||||
|
- [contracts-app/site/llm_client.py](../../contracts-app/site/llm_client.py)
|
||||||
|
- [contracts-app/site/extractor.py](../../contracts-app/site/extractor.py)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Финальный план рефакторинга — store + render
|
||||||
|
|
||||||
|
Согласовано: DeepSeek + Opus. Все открытые вопросы закрыты.
|
||||||
|
|
||||||
|
## Архитектурное решение
|
||||||
|
|
||||||
|
**Патерн:** единый `state` объект + одна `render(state)` + actions мутируют state.
|
||||||
|
|
||||||
|
```
|
||||||
|
action → мутация state → render(state)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Асинхронные операции (upload, classify, compare-SSE) — actions, пишут в state
|
||||||
|
- Синхронные трансформации (group, applyParseResult) — чистые функции
|
||||||
|
- DOM — проекция state, никто не читает из DOM
|
||||||
|
- `console.log(state)` показывает всё
|
||||||
|
|
||||||
|
## Структура state
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
state = {
|
||||||
|
batchId, contractId,
|
||||||
|
files: [{ id, name, size, status: { kind, pct, text, count }, parse, classify, supp_id, detailOpen }],
|
||||||
|
groups: [{
|
||||||
|
contractNumber, counterparty, documents, unresolved,
|
||||||
|
compare: { status, sections: { [suppId]: { header, body, ops } }, totalTime, collapsed }
|
||||||
|
}],
|
||||||
|
compareAll: { status, sections, totalTime },
|
||||||
|
ui: { steps: { upload, classify, groups, compare } },
|
||||||
|
_activeCompare: { es, timer }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Фазы
|
||||||
|
|
||||||
|
### Фаза 0 — Каркас
|
||||||
|
- Ввести `state`, `render(state)` (пока вызывает существующие рендеры)
|
||||||
|
- Соглашение: мутировал → `render()`
|
||||||
|
|
||||||
|
### Фаза 1 — Файлы
|
||||||
|
- `fileQueue` → `state.files`
|
||||||
|
- `renderTable` → `renderFiles(state)`
|
||||||
|
- `fileInput change` разбит:
|
||||||
|
- `onFilesSelected(files)` — оркестратор (10-15 строк)
|
||||||
|
- `reconcileSelection(newFiles)` — чистая мутация массива
|
||||||
|
- `addZipFiles(file)` — async-action
|
||||||
|
- `addRegularFile(file)` — async-action
|
||||||
|
- `applyParseResult(entry, parsed)` — чистая функция (убирает дубль zip/обычной ветки)
|
||||||
|
- `finalizeUpload()` — refreshSupps + stepDone + syncDB + кнопки
|
||||||
|
- `status` → структура `{ kind, pct, text, count }`, HTML через `statusToHTML(status)`
|
||||||
|
- Убрать `renderTable()` из обработчиков — только `render()` в конце action
|
||||||
|
|
||||||
|
### Фаза 2 — Группы
|
||||||
|
- `loadGroups` → `loadGroupsAction()` (fetch → state.groups) + `renderGroups(state)`
|
||||||
|
- `buildGroupCard` → `renderGroupCard(group)` — чистая HTML-функция
|
||||||
|
- **`markGroupDone` удалить** — готовность = `state.groups[gi].compare.status === 'done'`
|
||||||
|
- Убрать `window._groupsData`
|
||||||
|
- `renderGroups` пересобирает ВСЕ карточки целиком
|
||||||
|
|
||||||
|
### Фаза 3 — Сравнение/SSE
|
||||||
|
- Унификация через общие функции:
|
||||||
|
- `applyCompareEvent(target, event)` — чистая мутация SSE-события в target
|
||||||
|
- `startCompareSSE(url, target, { onDone })` — жизненный цикл SSE
|
||||||
|
- `renderCompareSections(sections)` — общий рендер таблиц ops
|
||||||
|
- `runCompareForGroup` → тонкая обёртка над `startCompareSSE`
|
||||||
|
- `llmBtn` → тонкая обёртка над `startCompareSSE`
|
||||||
|
- ~160 строк дублирования → ~60 строк общих + 2 вызова
|
||||||
|
|
||||||
|
### Фаза 4 — Чистка
|
||||||
|
- `contractId`, `batchId` → `state`
|
||||||
|
- Степпер → `renderStepper(state)`
|
||||||
|
- Промпты, чат, showText — вне scope (отдельная итерация)
|
||||||
|
|
||||||
|
## Верификация
|
||||||
|
|
||||||
|
1. После каждой правки: `node -c` на app.js + app_utils.js
|
||||||
|
2. После Фазы 2: node-тесты на чистые функции (`applyParseResult`, `statusToHTML`, `renderGroupCard`)
|
||||||
|
3. Ручной прогон upload→classify→group→compare после git push
|
||||||
|
4. Регресс: «Готово» не перезапускается, параллельные сравнения заблокированы, группы не затираются
|
||||||
|
|
||||||
|
## Релевантные файлы
|
||||||
|
|
||||||
|
- `contractor/deploy/app.js` — все фазы (~1040 строк)
|
||||||
|
- `contractor/deploy/app_utils.js` — removeFile/moveUp/moveDown (Фаза 1)
|
||||||
|
- `contractor/index.cfm` — DOM-скелет, версия v1.0.177, `?v=` для cache bust
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Opus 4.8 Analysis — 2026-06-24 — Auto-classification
|
||||||
|
|
||||||
|
## Главный вывод
|
||||||
|
Текущая модель — «один договор на сессию загрузки». upload.py: первый файл → contracts + supplement initial, остальные → additional к тому же. Связь документ→договор ТОЛЬКО через supplements. Авто-классификация — обратная задача: N файлов → M договоров. Это архитектурное изменение, не промпт.
|
||||||
|
|
||||||
|
## Q1: Поля в documents или staging?
|
||||||
|
**Поля в documents.** + batch_id (привязка к сессии загрузки) + parent_number (отдельно от own_number). 7 колонок:
|
||||||
|
doc_type, own_number, parent_number, doc_date (TEXT, не DATE), counterparty, classify_status, batch_id.
|
||||||
|
|
||||||
|
## Q2: classify в upload или отдельно?
|
||||||
|
**Отдельно.** Upload быстро (0.5с), классификация async. ThreadPoolExecutor(4-8) внутри /classify-batch. Статусная модель: classify_status='pending' → 'classified'/'failed'.
|
||||||
|
|
||||||
|
## Q3: header или весь документ?
|
||||||
|
**Умная выжимка.** header ~1500 симв + regex-хиты по маркерам (договор, №, от, соглашение) из всего документа + даты. Итого ~3000 симв на вход LLM.
|
||||||
|
|
||||||
|
## Q4: parent_contract_number — LLM или regex?
|
||||||
|
**Двухпроходный гибрид.** Проход 1: LLM извлекает строки per-doc. Проход 2: Python нормализует (regexp uppercase+буквы/цифры) и матчит supplements→contracts по parent_number. LLM не делает fuzzy-match.
|
||||||
|
|
||||||
|
## Q5: загрузка 2000 файлов
|
||||||
|
ZIP через /unzip-upload (переделать: store+parse серверно). Прогресс — polling /api/documents?batch=X, не SSE.
|
||||||
|
|
||||||
|
## Q6: группировка — фронт или бэк?
|
||||||
|
**Бэкенд.** Нормализация требует Python. GET /api/groups?batch=X возвращает готовые группы. apply-groups создаёт contracts+supplements.
|
||||||
|
|
||||||
|
## Q7: MVP
|
||||||
|
6 шагов с новыми файлами, ZIP не нужен. Multi-upload уже работает.
|
||||||
|
1. Миграция БД (7 колонок)
|
||||||
|
2. Слой данных (db/documents.py + seed classify prompt)
|
||||||
|
3. services/classify.py (выжимка + LLM + ThreadPoolExecutor)
|
||||||
|
4. services/grouping.py (normalize + group + apply)
|
||||||
|
5. Эндпоинты (/classify-batch, /api/groups, /apply-groups)
|
||||||
|
6. UI (загрузка → classify → polling → карточки групп → per-group Сравнить)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Opus Analysis — 2026-06-24 — Почему 5/6 и нет договора
|
||||||
|
|
||||||
|
## Три бага
|
||||||
|
|
||||||
|
### 1. Договор failed классификацию (корневая причина)
|
||||||
|
Самый большой документ (3000 символов выжимки) + max_tokens=500 → LLM обрезает JSON → _safe_json_parse падает → classify_status='failed'.
|
||||||
|
Именно он — тот 1 из 6, который не прошёл.
|
||||||
|
|
||||||
|
### 2. Мёртвый код в grouping.py (failed не видны)
|
||||||
|
```python
|
||||||
|
unmatched += [d for d in classified if d.get("classify_status") != "classified"]
|
||||||
|
```
|
||||||
|
Итерация по `classified` (уже отфильтрованному), условие всегда ложно.
|
||||||
|
Должно быть: `...for d in docs...`
|
||||||
|
|
||||||
|
### 3. normalize_number ломает сопоставление
|
||||||
|
`"03700_1"` → `"037001"`, `"03700"` → `"03700"`. Не совпадают.
|
||||||
|
Допники никогда не матчатся к базовому договору из-за суффикса _N.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Запрос для Opus — Рефакторинг фронтенда Contracts
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Проект: сверка договоров через LLM (colocation, ЦОД). Фронтенд — ванильный JS (app.js ~51KB + app_utils.js ~4KB). Бэкенд — Python http.server на ВМ. Фронт управляет пайплайном:
|
||||||
|
|
||||||
|
```
|
||||||
|
Загрузка → Парсинг → Классификация → Группировка → Сравнение
|
||||||
|
```
|
||||||
|
|
||||||
|
## Текущая проблема
|
||||||
|
|
||||||
|
Код эволюционировал organically. Глобальные переменные (`fileQueue`, `contractId`, `batchId`), манипуляции с DOM через `innerHTML`, состояние размазано по DOM и JS-переменным. Последствия:
|
||||||
|
- Сложно тестировать (нет изолированных юнитов)
|
||||||
|
- Сложно отлаживать (состояние не в одном месте)
|
||||||
|
- Баги с гонками, затиранием данных, повторными срабатываниями кнопок
|
||||||
|
- Любое изменение ломает что-то в другом месте
|
||||||
|
|
||||||
|
## Идея заказчика (НЕ догма — оцени и предложи лучшее)
|
||||||
|
|
||||||
|
Разбить на независимые функции-пайплайн:
|
||||||
|
|
||||||
|
```
|
||||||
|
upload(files) → [{ id, name, parsed }]
|
||||||
|
classify(docs) → [{ id, name, parsed, type, number, date, counterparty }]
|
||||||
|
group(classified) → [{ contract, documents[] }]
|
||||||
|
compare(group) → { contract, documents[], results[], expandable }
|
||||||
|
render(state) → DOM
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждая функция: чистые входные данные → новые выходные, не мутирует, не трогает DOM. Рендеринг — отдельно. Состояние хранится в одном месте.
|
||||||
|
|
||||||
|
## Что нужно от тебя
|
||||||
|
|
||||||
|
1. **Оценить идею.** Это правильный подход для ванильного JS? Или есть более подходящий паттерн (state machine, pub/sub, flux-like store)? Не изобретай велосипед — бери best practices для проектов такого масштаба (~50KB JS).
|
||||||
|
|
||||||
|
2. **Предложить архитектуру.** Как организовать код чтобы:
|
||||||
|
- Каждый этап пайплайна тестируем изолированно
|
||||||
|
- Состояние предсказуемо и отлаживаемо (console.log одного объекта показывает всё)
|
||||||
|
- DOM-рендеринг отделён от логики
|
||||||
|
- Минимальные изменения в текущем коде (не переписывать с нуля)
|
||||||
|
|
||||||
|
3. **План миграции.** Как перейти от текущего состояния к новому постепенно, не ломая работающий функционал.
|
||||||
|
|
||||||
|
## Релевантные файлы (читать)
|
||||||
|
|
||||||
|
- `contractor/deploy/app.js` — весь фронтенд (~51KB): renderTable, runClassify, loadGroups, runCompareForGroup, buildGroupCard, markGroupDone, syncDB, stepper, toggleClassifyDetail, showText, SSE handlers
|
||||||
|
- `contractor/deploy/app_utils.js` — утилиты: removeFile, escHtml, formatSize, formatDate, openAbout
|
||||||
|
- `contractor/index.cfm` — HTML-оболочка, DOM-структура
|
||||||
|
|
||||||
|
## Игнорировать
|
||||||
|
|
||||||
|
- `contractor/deploy/convert_server.py` и все Python-файлы — бэкенд, не рефакторим
|
||||||
|
- `contractor/deploy/db/`, `contractor/deploy/services/` — бэкенд
|
||||||
|
- `contractor/*.cfm` кроме index.cfm — старый Lucee-код
|
||||||
|
- `contracts-app/`, `contracts-vm/`, `History/`, `DOC/`, `FILES/` — не относится
|
||||||
|
|
||||||
|
## Ключевые функции для анализа
|
||||||
|
|
||||||
|
| Функция | Строки (app.js) | Что делает |
|
||||||
|
|---------|-----------------|------------|
|
||||||
|
| renderTable | 57-75 | Рендер таблицы файлов |
|
||||||
|
| fileInput change | 76-290 | Загрузка + парсинг |
|
||||||
|
| runClassify | 308-360 | Классификация |
|
||||||
|
| loadGroups | 362-430 | Группировка + рендер карточек |
|
||||||
|
| buildGroupCard | 433-442 | Карточка необработанной группы |
|
||||||
|
| markGroupDone | 444-478 | Карточка обработанной группы |
|
||||||
|
| runCompareForGroup | 483-590 | Сравнение группы (apply → SSE) |
|
||||||
|
| toggleClassifyDetail | 91-130 | Раскрытие промежуточных результатов |
|
||||||
|
| syncDB | 26-34 | Синхронизация БД с таблицей |
|
||||||
|
| stepper | 38-56 | StepDone/StepActive/ResetStepper |
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Без фреймворков (ванильный JS)
|
||||||
|
- Без классов и наследования (не ООП)
|
||||||
|
- Минимум зависимостей между модулями
|
||||||
|
- Фокус на тестируемость и отлаживаемость
|
||||||
|
- Не переписывать с нуля — мигрировать постепенно
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Ответ Opus на decoupling — моё мнение
|
||||||
|
|
||||||
|
## По трём открытым вопросам
|
||||||
|
|
||||||
|
### 1. Перерисовывать целиком vs точечно?
|
||||||
|
**Целиком.** Для ~50KB JS и десятка DOM-элементов перерисовка всей карточки/таблицы — доли миллисекунды. Точечные патчи создают ровно те баги, которые мы сегодня чинили (затирание, рассинхрон). `render(state)` должен тупо пересобрать всё заново.
|
||||||
|
|
||||||
|
### 2. Промпты/чат/showText в scope?
|
||||||
|
**Нет**, отдельная итерация. Они изолированы (модалки), не участвуют в пайплайне. Не добавлять сложности.
|
||||||
|
|
||||||
|
### 3. Node-тесты на чистые билдеры?
|
||||||
|
**Да.** После Фазы 2, когда появятся `buildFileRowHTML(state)` и `buildGroupCardHTML(group)`. Прогнать на фикстурах — уберёт регрессии на рендеринг.
|
||||||
|
|
||||||
|
## По плану в целом
|
||||||
|
|
||||||
|
**Согласен с порядком фаз.** Фаза 0 (каркас) → Фаза 1 (files) → Фаза 2 (groups) → Фаза 3 (SSE). Это правильный порядок от простого к сложному.
|
||||||
|
|
||||||
|
**Согласен с удалением `markGroupDone`.** Чтение `bodyEl.innerHTML` из DOM — это то самое «состояние размазано по DOM», которое мы лечим. `state.groups[gi].compare.status === 'done'` — правильный источник истины.
|
||||||
|
|
||||||
|
**Дополнение по SSE.** Opus правильно отметил что SSE-события мутируют state + render(). Но есть нюанс: `EventSource` и таймеры нужно привязывать к `state._activeCompare`. При старте нового сравнения проверять `if (state._activeCompare) { state._activeCompare.es.close(); }` — это уберёт гонки на уровне state, а не DOM.
|
||||||
|
|
||||||
|
**Дополнение по рендерингу.** `render(state)` должна быть идемпотентной: повторный вызов с тем же state даёт тот же DOM. Это гарантирует что «лишний render()» не сломает ничего.
|
||||||
|
|
||||||
|
## Есть ли ещё вопросы к Опусу?
|
||||||
|
|
||||||
|
Три уточнения:
|
||||||
|
|
||||||
|
1. **`fileInput change` handler** — ~200 строк, самая сложная функция. Как разбить на под-actions: `selectFiles`, `removeOrphans`, `uploadOne`, `parseOne`? Или оставить монолитом но внутри state?
|
||||||
|
|
||||||
|
2. **SSE-дублирование.** `runCompareForGroup` и `llmBtn` имеют ~80% одинакового кода (extract_start, llm_done, applied). Унифицировать в `runCompare(state, contractId)` с флагом `scope: 'group' | 'all'`?
|
||||||
|
|
||||||
|
3. **`status` в state.files.** Сейчас это HTML-строка (`'<span class="status-ok">✓ 25 эл. (0.0с)</span>'`). Заменить на структуру `{ ok: true, elements: 25, time: 0.0 }` и рендерить через `renderFileStatus(file)`?
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Ответ Опуса — isolated training page / feedback storage
|
||||||
|
|
||||||
|
Дата: 26.06.2026 | Ответ на вопросы по отдельной странице "обучения" для проекта «Сверка договоров».
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
- Стек: Lucee/CFML 6.0, файловая маршрутизация (`teach.cfm` → `/teach`), PostgreSQL `baza`.
|
||||||
|
- Рабочий compare-пайплайн не трогаем: новая фича должна жить полностью изолированно.
|
||||||
|
- Цель: отдельная страница с тем же UI-скелетом, что и compare results, но с возможностью оставлять замечания по строкам таблицы операций и сохранять их в БД для дальнейшего анализа агентом.
|
||||||
|
|
||||||
|
## 1. Минимальная схема `feedback`
|
||||||
|
|
||||||
|
Для MVP достаточно плоских колонок для агрегации и `JSONB` для значений:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
|
||||||
|
contract_id UUID,
|
||||||
|
supplement_id UUID,
|
||||||
|
event_seq INTEGER, -- NULL для missed/document
|
||||||
|
|
||||||
|
scope TEXT NOT NULL, -- 'row' | 'missed' | 'document'
|
||||||
|
verdict TEXT NOT NULL, -- 'correct' | 'error'
|
||||||
|
error_type TEXT, -- wrong_price|wrong_qty|wrong_name|wrong_date|extra_row|missed_row|wrong_action|wrong_mode
|
||||||
|
field TEXT, -- price|qty|sum|name|date_start|action|mode
|
||||||
|
|
||||||
|
service_name TEXT,
|
||||||
|
llm_value JSONB,
|
||||||
|
correct_value JSONB,
|
||||||
|
|
||||||
|
prompt_version TEXT,
|
||||||
|
doc_mode TEXT, -- partial | full_replace
|
||||||
|
comment TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feedback_error ON feedback(error_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feedback_prompt ON feedback(prompt_version);
|
||||||
|
```
|
||||||
|
|
||||||
|
Рекомендуемый фиксированный набор `error_type`:
|
||||||
|
`wrong_price, wrong_qty, wrong_name, wrong_date, extra_row, missed_row, wrong_action, wrong_mode`.
|
||||||
|
|
||||||
|
## 2. UI первой версии vs отложить
|
||||||
|
|
||||||
|
### В MVP
|
||||||
|
|
||||||
|
- Колонка `⚠` у каждой строки таблицы операций с мини-формой: выбор `error_type` + опционально правильное значение + комментарий.
|
||||||
|
- Кнопка `[✓ всё верно]` на карточке ДС для положительного сигнала (`scope=document, verdict=correct`).
|
||||||
|
- Кнопка `[➕ пропущена позиция]` для случая, когда позиция была пропущена (`scope=missed`).
|
||||||
|
|
||||||
|
### Отложить
|
||||||
|
|
||||||
|
- Инлайн-редактирование значений прямо в ячейках.
|
||||||
|
- Статистика и дашборды на самой странице.
|
||||||
|
- Модерация замечаний, роли, удаление чужих записей.
|
||||||
|
- Разбор новых загрузок на этой странице; работать только по уже разобранным документам из БД.
|
||||||
|
|
||||||
|
## 3. Связь с исходной операцией
|
||||||
|
|
||||||
|
Правильная связь — логическая, без FK и без вмешательства в рабочий pipeline: `(contract_id, supplement_id, event_seq)`.
|
||||||
|
|
||||||
|
Почему так:
|
||||||
|
|
||||||
|
- `spec_events` уже хранит операции как `spec_events(contract_id, supplement_id, seq, action, new_values, ...)`.
|
||||||
|
- `feedback` просто повторяет эти значения как обычные поля.
|
||||||
|
- Без FK исключается риск связать feedback с жизненным циклом боевых событий или сломать вставки при повторном разборе.
|
||||||
|
|
||||||
|
Если `event_seq` когда-то переедет из-за пересборки данных, в `llm_value` / `correct_value` нужно сохранять снимок операции (`action` + `new_values`), чтобы замечание оставалось интерпретируемым.
|
||||||
|
|
||||||
|
## 4. `prompt_version`
|
||||||
|
|
||||||
|
Версию промпта хранить нужно, иначе нельзя считать регрессию между изменениями.
|
||||||
|
|
||||||
|
Но сейчас результат разбора не штампуется версией промпта, поэтому для MVP предлагается практический компромисс:
|
||||||
|
|
||||||
|
- `/teach` при сохранении замечания пишет текущую активную версию промпта из БД.
|
||||||
|
- Это честно помечается как версия на момент проверки, а не на момент исходного разбора.
|
||||||
|
- Правильная фиксация `prompt_version` в результате разбора — отдельная задача основного pipeline, не часть isolated feedback flow.
|
||||||
|
|
||||||
|
## 5. Что исключить
|
||||||
|
|
||||||
|
### PII / обезличивание
|
||||||
|
|
||||||
|
- Не хранить название/ИНН клиента, номер договора, ФИО, подписантов, реквизиты, email, телефоны.
|
||||||
|
- Не хранить оригинальный текст документа и байты файла.
|
||||||
|
|
||||||
|
### Изоляция рабочих данных
|
||||||
|
|
||||||
|
- Не использовать `ALTER` существующих таблиц.
|
||||||
|
- Не добавлять FK, триггеры или write-логики в `spec_events`, `spec_current`, `contracts`, `supplements`.
|
||||||
|
- `/teach` должен писать только в `feedback` через отдельный параметризованный endpoint.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
Опус подтвердил, что MVP должен быть:
|
||||||
|
|
||||||
|
1. Отдельной страницей с тем же UX-скелетом, что и compare results.
|
||||||
|
2. Отдельным write endpoint и отдельной таблицей `feedback`.
|
||||||
|
3. Полностью изолированным от рабочего compare-пайплайна.
|
||||||
|
4. Приспособленным для SQL-аналитики агентом через плоские колонки + `JSONB`.
|
||||||
|
|
||||||
|
Главный принцип: это не online-training, а сбор структурированного feedback для последующего анализа и планирования изменений.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Ответ Опуса — режим «исправление ошибок / обучение»
|
||||||
|
|
||||||
|
Дата: 26.06.2025 | В ответ на обсуждение feedback-цикла.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Суть
|
||||||
|
|
||||||
|
Режим: **юзер загружает документы → система выдаёт результат → юзер правит ошибки → правки сохраняются**.
|
||||||
|
|
||||||
|
Это не «обучение модели», а **накопление эталонных данных** (golden dataset) через естественный интерфейс исправления. Юзер не размечает абстрактно — он правит конкретный неверный результат.
|
||||||
|
|
||||||
|
## Что уже есть под это
|
||||||
|
|
||||||
|
- LLM возвращает поток операций: `ADD` / `UPDATE` / `DELETE` / `UNRESOLVED`
|
||||||
|
- Применяются через событийную модель (`apply_events.cfm`)
|
||||||
|
- Правка юзера = **исправленный поток операций**
|
||||||
|
- Разница «LLM выдал» vs «юзер поправил» = **чистый сигнал ошибки**
|
||||||
|
|
||||||
|
## Три уровня (не путать)
|
||||||
|
|
||||||
|
| Уровень | Что | Когда |
|
||||||
|
|---------|-----|-------|
|
||||||
|
| **Регрессия** | Правки → golden-набор → прогон промпта → % ошибок | Сразу |
|
||||||
|
| **Prompt-learning** | Анализ частых ошибок → правка промпта (few-shot/глоссарий) | Периодически |
|
||||||
|
| **Fine-tuning** | Дообучение модели на сотнях/тысячах примеров | Не сейчас |
|
||||||
|
|
||||||
|
## Чего НЕ делать
|
||||||
|
|
||||||
|
- **Автоматически вкручивать правки в промпт** — переобучение, конфликты, рост токенов
|
||||||
|
- Правильно: правки → накопитель → куратор анализирует агрегаты → batch-обновление промпта → регрессия
|
||||||
|
|
||||||
|
## Что заложить в дизайн
|
||||||
|
|
||||||
|
1. **Структурировать тип ошибки:** «пропущена строка», «неверная цена», «UPDATE вместо ADD», «ложный дубликат»
|
||||||
|
2. **Версия промпта** при каждом результате — чтобы знать актуальность ошибки
|
||||||
|
3. **Конфиденциальность** — реальные договоры с реквизитами копятся в БД, обсудить с заказчиком
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Решение:** отложить. Позже вернуться и спроектировать.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Часть 2 — Конкретный UI для отметки ошибок
|
||||||
|
|
||||||
|
Дата: 26.06.2025 | Опус изучил реальный вывод (карточка + таблица операций) и предложил привязку к элементам интерфейса.
|
||||||
|
|
||||||
|
## Привязка: не к абзацам, а к строкам таблицы операций
|
||||||
|
|
||||||
|
Результат — **таблица операций** (`Действие / Услуга / Цена / Кол-во / Сумма / Дата`), а не текст-простыня. Замечание цепляется к строке таблицы, а не к абзацу исходника.
|
||||||
|
|
||||||
|
### Как выглядит (на реальном примере)
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ допник-1-XXX002-01200_3.docx — 2 оп., partial (14с) [✓ всё верно]
|
||||||
|
+2 ~0 -0
|
||||||
|
|
||||||
|
Действие Услуга Цена Кол-во Сумма Дата ⚠
|
||||||
|
ADD WAF: Positive Technologies, в составе… 104021.67 1 104021.67 2026-03-30 [⚠]
|
||||||
|
ADD Облачный диск Valo Cloud, в составе… 68700 1 68700 2026-02-01 [⚠]
|
||||||
|
|
||||||
|
[ ➕ Система пропустила позицию ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Клик по `[⚠]` → мини-форма под строкой
|
||||||
|
|
||||||
|
```
|
||||||
|
Тип ошибки: ( ) неверная цена/сумма
|
||||||
|
( ) неверное кол-во
|
||||||
|
( ) неверное наименование услуги
|
||||||
|
(•) лишняя строка — этой операции быть не должно
|
||||||
|
( ) неверное действие (должно быть UPDATE/DELETE, а не ADD)
|
||||||
|
( ) неверная дата
|
||||||
|
Правильное значение: [_____________] (необязательно)
|
||||||
|
Комментарий: [_____________] (необязательно)
|
||||||
|
[ Сохранить ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `[✓ всё верно]` — вверху карточки
|
||||||
|
|
||||||
|
Один клик = положительный сигнал, без расписывания.
|
||||||
|
|
||||||
|
### `[➕ Система пропустила позицию]` — под таблицей
|
||||||
|
|
||||||
|
Для случая, когда услуга была в документе, но LLM её не извлекла. Открывает форму ввода пропущенной строки.
|
||||||
|
|
||||||
|
## Что уходит в базу (обезличенно)
|
||||||
|
|
||||||
|
```
|
||||||
|
operation: ADD
|
||||||
|
service: "Облачный диск Valo Cloud, в составе…" ← без названия клиента
|
||||||
|
field: price
|
||||||
|
llm_value: 68700
|
||||||
|
correct: 68000
|
||||||
|
error_type: wrong_price
|
||||||
|
prompt_version: v1.0.178
|
||||||
|
```
|
||||||
|
|
||||||
|
Никаких названий компаний, ФИО, № договора — только структура услуги и числа.
|
||||||
|
|
||||||
|
## Почему так, а не поле у каждого абзаца
|
||||||
|
|
||||||
|
- Реальный вывод — **таблица**, а не текст. Абзацев нет, есть операции
|
||||||
|
- Привязка к операции даёт агрегацию по типу ошибки
|
||||||
|
- Готовая дельта «LLM выдала X → правильно Y» как обучающий сигнал
|
||||||
|
- Ровная укладка в событийную модель (`apply_events.cfm`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Решение:** отложить. Ждать «делай» для реализации.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Часть 3 — Текст для заказчика
|
||||||
|
|
||||||
|
**Этап опытной эксплуатации (обучение системы)**
|
||||||
|
|
||||||
|
На первое время предлагаем режим проверки: вы загружаете реальные документы, система выдаёт результат, а вы отмечаете ошибки — что распознано неверно (пропущена строка, неверная цена, неправильное сопоставление и т.п.).
|
||||||
|
|
||||||
|
Эти отметки **в обезличенном виде** (без названий компаний, ФИО, реквизитов и иных персональных данных) накапливаются в базе и используются для дальнейшей настройки и обучения системы.
|
||||||
|
|
||||||
|
Это позволит откалибровать сервис на ваших реальных договорах, а не на тестовых примерах, и системно повышать точность.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Запрос для Opus — План: группировка, прогресс, промежуточные результаты
|
||||||
|
|
||||||
|
## Что сказал заказчик (дословно)
|
||||||
|
|
||||||
|
> Не распознано (8 файлов)
|
||||||
|
> [supplement] допник-1-XXX002-01200_3.docx ... — нет базового договора №01219_3
|
||||||
|
> ...
|
||||||
|
> зачем нам базовый договор? Вся информация есть в допниках и спеках
|
||||||
|
|
||||||
|
> Было бы хорошо пояснить или показать процесс, что в каком порядке происходит. Так видно только текущую операцию
|
||||||
|
|
||||||
|
> наверно я захочу иметь возможность посмотреть любые промежуточные результаты
|
||||||
|
|
||||||
|
## Что нужно
|
||||||
|
|
||||||
|
Заказчик хочет три улучшения (без фанатизма, главное — устойчивость и понятный UI):
|
||||||
|
|
||||||
|
### #1 — Группировка без базовых договоров
|
||||||
|
|
||||||
|
**Проблема:** сейчас `group_documents()` требует contract-файл как якорь группы. Без него допники/спеки попадают в unresolved с текстом «нет базового договора №X». Заказчик: «зачем нам базовый договор? Вся информация есть в допниках и спеках».
|
||||||
|
|
||||||
|
**Нужно:** группировать документы по `parent_number` / `own_number`, даже если contract-файл отсутствует в загрузке. Создавать «виртуальную» группу без contract-файла.
|
||||||
|
|
||||||
|
**Вопросы:**
|
||||||
|
1. Алгоритм: что приоритетнее — `parent_number` от допника или `own_number` от спеки? Если оба ссылаются на один нормализованный номер — это одна группа?
|
||||||
|
2. Если два допника с одним `parent_number`, но разными `counterparty` — одна группа или разные?
|
||||||
|
3. Как назвать группу без contract-файла: `"№01300_2 — ЗАО XXX003"` из данных классификации? Достаточно?
|
||||||
|
4. Минимальный diff в `group_documents()` — чтобы не сломать текущую логику с contract-файлами?
|
||||||
|
|
||||||
|
### #2 — Прогресс пайплайна
|
||||||
|
|
||||||
|
**Проблема:** юзер видит только статус текущей операции. Непонятно что уже сделано, что предстоит.
|
||||||
|
|
||||||
|
**Нужно:** визуальная шкала этапов с иконками статуса.
|
||||||
|
|
||||||
|
**Вопросы:**
|
||||||
|
1. Достаточно 4 этапов: Загрузка → Классификация → Группировка → Сравнение? (Парсинг — подэтап загрузки, не показывать отдельно)
|
||||||
|
2. Где разместить: в топбаре (всегда видно, не скроллится) или в карточке с результатами?
|
||||||
|
3. При переклассификации после удаления/добавления файла — сбрасывать всю шкалу или только затрагиваемые этапы?
|
||||||
|
|
||||||
|
### #3 — Промежуточные результаты
|
||||||
|
|
||||||
|
**Проблема:** юзер хочет видеть что LLM вернула на каждом шаге: сырой ответ, как определился номер/тип/дата.
|
||||||
|
|
||||||
|
**Нужно:** раскрывающийся блок с деталями для каждого файла.
|
||||||
|
|
||||||
|
**Вопросы:**
|
||||||
|
1. Что хранить: сырой ответ LLM + распарсенный JSON? Достаточно двух новых полей в `documents`?
|
||||||
|
2. Где показывать: раскрывающийся блок под строкой файла в таблице? Или модалка при клике на статус?
|
||||||
|
3. Нужно ли для сравнения (process-v2 SSE) или только для классификации?
|
||||||
|
|
||||||
|
### #4 — Общие ограничения
|
||||||
|
|
||||||
|
Что из трёх самое трудозатратное и что можно упростить без потери юзабилити?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Релевантные файлы (читать)
|
||||||
|
|
||||||
|
### Группировка (#1)
|
||||||
|
- `contractor/deploy/services/grouping.py` — `group_documents()`, `normalize_number()`, `apply_groups()`
|
||||||
|
- `contractor/deploy/db/documents.py` — поля `doc_type`, `own_number`, `parent_number`, `counterparty`, `classify_status`
|
||||||
|
- `contractor/deploy/db/contracts.py` — `insert()`, поля `number`, `client`
|
||||||
|
- `contractor/deploy/db/supplements.py` — `insert()`, `list_by_contract()`
|
||||||
|
|
||||||
|
### Прогресс-бар (#2) + Промежуточные результаты (#3)
|
||||||
|
- `contractor/index.cfm` — HTML-оболочка, топбар (`.topbar`, `position: sticky`), карточки, модалки
|
||||||
|
- `contractor/deploy/app.js` — весь фронтенд: загрузка, `runClassify()`, `loadGroups()`, `runCompareForGroup()`, `showClassifyBtn()`, рендеринг таблицы и групп
|
||||||
|
- `contractor/deploy/app_utils.js` — утилиты: `removeFile()`, `renderTable()`
|
||||||
|
- `contractor/deploy/convert_server.py` — роутер (`do_GET`, `do_POST`, `do_DELETE`), SSE (`_handle_process_v2`), эндпоинты `/api/classify-batch`, `/api/groups`, `/api/batch-progress`, `/api/sync`
|
||||||
|
|
||||||
|
### Общий контекст
|
||||||
|
- `contractor/deploy/services/classify.py` — `classify_batch()`, `_smart_extract()`, `_call_llm_classify()`, `_safe_json_parse()`
|
||||||
|
- `contractor/deploy/services/process.py` — `run_pipeline()` (SSE для сравнения: extract/diff)
|
||||||
|
- `contractor/deploy/services/grouping.py` — `group_documents()`, `apply_groups()`
|
||||||
|
- `contractor/deploy/llm_prompt.py` — `build_prompt()`, `build_classify_prompt()`
|
||||||
|
- `contractor/deploy/db/connection.py` — `query()`, `execute()`, `execute_returning()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Игнорировать (не относится к делу)
|
||||||
|
|
||||||
|
- `contracts-app/` — старый Python-бэкенд (Flask), не используется
|
||||||
|
- `contracts-vm/` — старые конфиги ВМ
|
||||||
|
- `DOC/`, `FILES/` — документация, заметки
|
||||||
|
- `dogovora/` — тестовые файлы договоров
|
||||||
|
- `history/`, `History/` — старые сессионные заметки (кроме этого файла)
|
||||||
|
- `contractor/*.cfm` кроме `index.cfm` — старый Lucee-код, не используется
|
||||||
|
- `contractor/deploy/nginx-contracts.conf` — конфиг nginx
|
||||||
|
- `contractor/deploy/convert_doc.py` — конвертер .doc → .docx
|
||||||
|
- `contractor/deploy/sync.sh` — скрипт деплоя
|
||||||
|
- `contractor/upload.cfm`, `contractor/process.cfm`, `contractor/parser.cfm` и т.д. — старый Lucee-код
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура (кратко)
|
||||||
|
|
||||||
|
- **Фронт:** `index.cfm` (Lucee, только HTML-оболочка) → грузит `app.js` + `app_utils.js` с ВМ (`https://contracts.kube5s.ru/static/`)
|
||||||
|
- **Бэкенд:** Python 3.12 `http.server` + `ThreadingMixIn` на ВМ (5.172.178.213), порт 8766, systemd-сервис `contracts`
|
||||||
|
- **Все endpoint'ы:** в `convert_server.py` (один файл-роутер, ~400 строк)
|
||||||
|
- **БД:** PostgreSQL 16, прямой доступ через `psycopg2`, connection pool
|
||||||
|
- **Таблицы:** `documents`, `supplements`, `contracts`, `spec_events`, `spec_current`, `prompts`
|
||||||
|
- **LLM:** gpt-oss-120b через `api.aillm.ru`, httpx с http2, `temperature=0.1`, `max_tokens=8000`
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Ответ Opus на бриф — моё мнение
|
||||||
|
|
||||||
|
## Что Opus сделал хорошо
|
||||||
|
|
||||||
|
1. **Сверил бриф с кодом** — подтвердил что группировка матчит по `normalize_number`, что прогресс-данные уже есть в `/api/batch-progress`, что сырой LLM-ответ не хранится. Без этого был риск писать план «в воздух».
|
||||||
|
|
||||||
|
2. **Нашёл дубликат `_handle_cleanup`** — я знал про это но не зафиксировал. Opus заметил сам. Побочная находка, полезно.
|
||||||
|
|
||||||
|
3. **Риск baseline для виртуальных групп** — ключевое. Если первый документ виртуальной группы — допник, а не базовый договор, то `run_pipeline()` может построить baseline из допника, а не из полной спецификации. Opus прав: это надо проверить перед релизом #1.
|
||||||
|
|
||||||
|
4. **Приоритеты** — правильно оценил: #3 тяжелее всего (миграция БД + 3 слоя), #2 легче всего (только UI). Совпадает с моей оценкой.
|
||||||
|
|
||||||
|
## Где Opus ошибся или недоработал
|
||||||
|
|
||||||
|
1. **Схема БД** — написал «схема `documents` создаётся на ВМ, в репозитории её нет». Это правда, но миграция через ALTER TABLE на лету — хрупко. Лучше через `seed_defaults()` или отдельный `ensure_schema()`, как уже сделано для prompts. Opus не предложил механизм.
|
||||||
|
|
||||||
|
2. **Два допника с одним parent_number, разными counterparty** — Opus рекомендует разделять. Я бы наоборот: группировать по номеру, игнорировать counterparty. Потому что один договор может иметь одного контрагента в базовом договоре, а в допнике он может быть написан иначе (сокращение, другая оргформа). Риск ложного разделения выше чем риск ложного объединения.
|
||||||
|
|
||||||
|
3. **Прогресс-бар в топбаре** — идея ок, но топбар уже содержит лого + заголовок + «О сервисе». 4 этапа + текст займут место. Возможно лучше сделать отдельную строку под топбаром или внутри карточки результатов. Opus не учёл текущую вёрстку.
|
||||||
|
|
||||||
|
4. **classify_raw vs parsed** — Opus предлагает хранить только `classify_raw`. Я бы хранил и то и другое: `classify_raw` (текст) + `classify_json` (jsonb). Потому что сырой ответ может быть невалидным JSON, а для отладки нужны оба. Но это увеличивает трудозатраты — ок, можно только raw для начала.
|
||||||
|
|
||||||
|
## Что я бы сделал иначе
|
||||||
|
|
||||||
|
1. **Порядок реализации:** #1 → #3 → #2. Потому что #1 (группировка) — самое востребованное заказчиком прямо сейчас. #3 (промежуточные результаты) даст данные для отладки #1 если что-то пойдёт не так. #2 (прогресс-бар) — вишенка, можно последней.
|
||||||
|
|
||||||
|
2. **Для #1:** вместо «виртуальной группы» — просто создавать реальный contract с флагом `is_virtual=true` или `number` из `parent_number`. Тогда `apply_groups()` не нужно менять вообще — contract уже существует, supplement просто привязывается. Меньше спецкейсов.
|
||||||
|
|
||||||
|
3. **Для #3:** вместо ALTER TABLE на лету — добавить колонку в `ensure_schema()` который вызывается при старте. Идемпотентно: `ADD COLUMN IF NOT EXISTS`. Уже есть прецедент с `_ensure_classify_prompt()`.
|
||||||
|
|
||||||
|
## Вердикт
|
||||||
|
|
||||||
|
План Opus — добротный, можно брать за основу. Три поправки выше (порядок, виртуальный contract через флаг, механизм миграции) — и можно делать.
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
# Полные ответы Опуса — изолированная страница /teach (feedback + обучение)
|
||||||
|
|
||||||
|
Дата: 26.06.2026 | Сохранено post-factum из чат-сессии.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ЧАСТЬ 1: Ответ про few-shot и эталонный датасет
|
||||||
|
|
||||||
|
Хороший вопрос. Дай я сначала посмотрю, как сейчас устроены эти «6 файлов» и формирование промпта, чтобы мнение было по делу.
|
||||||
|
|
||||||
|
Searching codebase for "формирование промпта для LLM на основе эталонных файлов few-shot examples"
|
||||||
|
|
||||||
|
Searched for regex `6 (файл|file|example|эталон|reference)`, 1 result
|
||||||
|
|
||||||
|
Searched for regex `few.?shot|эталон|example|пример|reference|опорн` (`**/contracts-app/**/*.py`), no results
|
||||||
|
|
||||||
|
Searched for files matching `**/llm_prompt.py`, 1 match
|
||||||
|
|
||||||
|
Searched for files matching `**/prompt*.{py,cfm}`, 2 matches
|
||||||
|
|
||||||
|
Searched for regex `opus_cases|out_100files|6 файл|six`, 2 results
|
||||||
|
|
||||||
|
Read file:///home/naeel/nubes/contracts/contractor/deploy/llm_prompt.py#1-1, lines 1 to 120
|
||||||
|
|
||||||
|
Read file:///home/naeel/nubes/contracts/testgen/opus_cases
|
||||||
|
|
||||||
|
Посмотрел, как сейчас устроено: промпт в llm_prompt.py — это хардкод-инструкция с доменным глоссарием и парой ручных few-shot примеров (`ПРИМЕР 1`, `ПРИМЕР 2`), а «6 файлов» — это по сути синтетические кейсы из opus_cases для проверки.
|
||||||
|
|
||||||
|
Моё мнение по фиче.
|
||||||
|
|
||||||
|
## Идея здравая, но есть важная развилка
|
||||||
|
|
||||||
|
Реальные проверенные документы — это очень ценно. Синтетика (gen_opus.py) не воспроизводит грязь реальных договоров: кривые таблицы, объединённые ячейки, «мощность в составе названия», нестандартные формулировки ДС, опечатки в суммах. На 6 искусственных файлах легко получить иллюзию качества, которая рассыпется на первом же настоящем пакете.
|
||||||
|
|
||||||
|
Но ключевой вопрос: **что именно строить из архива?** Тут смешаны две разные вещи.
|
||||||
|
|
||||||
|
## 1. Эталонный датасет (input → проверенный output) — да, делать
|
||||||
|
|
||||||
|
Самое полезное. Но важно: «загрузить архив документов» недостаточно. Чтобы это работало, на каждый документ нужен **проверенный правильный результат** (итоговая спецификация / список ops). Без эталонного ответа это просто куча PDF — не на чем калибровать.
|
||||||
|
|
||||||
|
То есть фича должна быть не «загрузи архив», а «загрузи архив + зафиксируй проверенную спецификацию как ground-truth». Тогда получаем regression-набор: прогнал промпт → сравнил с эталоном → видишь реальный процент ошибок.
|
||||||
|
|
||||||
|
## 2. Авто-генерация промпта из этих файлов — осторожно
|
||||||
|
|
||||||
|
Полностью «формировать промпт» из загруженных файлов я бы не делал. Риски:
|
||||||
|
- **Раздувание контекста** — если пихать реальные договоры целиком как few-shot, промпт пухнет, растёт цена/латентность, и модель начинает копировать формат конкретного образца.
|
||||||
|
- **Переобучение под layout** — закладываешь форматы 3-х клиентов, ломается на четвёртом.
|
||||||
|
- **Конфиденциальность** — реальные договоры с реквизитами/ценами уезжают в LLM на каждом запросе. Это надо явно проговорить с заказчиком.
|
||||||
|
|
||||||
|
Лучше из архива **извлекать паттерны, а не сам текст**: новые единицы измерения, варианты названий услуг, типы формулировок ДС («изложить в новой редакции» и т.п.) — и обогащать ими глоссарий и правила в промпте. 1-2 самых показательных кейса можно добавить как few-shot, остальное — в eval-набор.
|
||||||
|
|
||||||
|
## Что я бы предложил по факту
|
||||||
|
|
||||||
|
- **Разделить два контура**: (а) `golden dataset` из реальных пар вход→проверенный_выход для регрессии; (б) сам промпт — оставить компактным, обогащать его выводами из анализа датасета, а не сырыми файлами.
|
||||||
|
- Фича «загрузка архива» → ведёт в **разметку/верификацию** (человек подтверждает правильный результат), а не сразу в промпт.
|
||||||
|
- На этом наборе гонять метрику (precision/recall по ops), и менять промпт только если метрика растёт.
|
||||||
|
|
||||||
|
Резюме: направление правильное — уходить от 6 синтетических файлов надо. Но ценность не в «архиве документов», а в **архиве проверенных пар документ↔результат**. И этим калибровать/тестировать, а не механически генерировать промпт.
|
||||||
|
|
||||||
|
Это обсуждение — код не трогаю. Скажешь «делай» по конкретному пункту — приступлю.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ЧАСТЬ 2: Ответ про «типа обучение» (feedback-learning цикл)
|
||||||
|
|
||||||
|
Да, это сильнее предыдущего варианта — и вот почему, плюс где подвох.
|
||||||
|
|
||||||
|
## Почему это лучше «загрузки архива»
|
||||||
|
|
||||||
|
Главная проблема прошлого варианта была: откуда взять «проверенный правильный ответ». Здесь она решается сама собой — **интерфейс исправления ошибок и есть инструмент разметки**. Юзер не размечает абстрактно, а правит конкретный результат системы. На выходе автоматически копится то самое: пара «вход → проверенный выход». Это правильный способ построить эталонный датасет.
|
||||||
|
|
||||||
|
И что важно — у вас уже есть подходящая структура под это. Результат LLM — это поток операций (`ADD` / `UPDATE` / `DELETE` / `UNRESOLVED`) в llm_prompt.py, а применяются они через событийную модель (apply_events.cfm). Значит правка юзера — это **исправленный поток операций**, а разница «что выдала LLM» vs «как поправил юзер» = чистый сигнал ошибки. Это удобно: не свободный текст «тут неверно», а конкретная дельта.
|
||||||
|
|
||||||
|
## Но «типа обучения» — это важная оговорка
|
||||||
|
|
||||||
|
Само по себе это **не обучение модели**. Цикл «учится» только если правки где-то сохраняются и переиспользуются. Если правка живёт в рамках одной сессии и исчезает — система не умнеет, это просто ручная корректировка. Так что ключевое в фиче — не сам акт правки, а **накопление и переиспользование** правок.
|
||||||
|
|
||||||
|
Дальше есть три уровня, и не надо их путать:
|
||||||
|
|
||||||
|
1. **Регрессия** (самое дешёвое и полезное сразу) — правки копятся как golden-набор, на нём гоняется промпт, видно реальный % ошибок.
|
||||||
|
2. **Prompt-learning** — из частых ошибок осознанно дополняется глоссарий/правила/few-shot в промпте. Это и есть практичное «обучение» в вашем случае.
|
||||||
|
3. **Fine-tuning модели** — реальное дообучение. Дорого, нужен объём (сотни-тысячи примеров), отдельная инфраструктура. На старте не нужно.
|
||||||
|
|
||||||
|
## Где подвох — не вкручивать правки в промпт автоматически
|
||||||
|
|
||||||
|
Соблазн: «каждую правку юзера сразу добавлять в промпт». Так делать нельзя:
|
||||||
|
- **переобучение** под конкретные форматы;
|
||||||
|
- **рост контекста** и цены;
|
||||||
|
- **конфликтующие правки** от разных юзеров → промпт деградирует.
|
||||||
|
|
||||||
|
Правильнее: правки идут в накопитель → периодически человек/куратор смотрит агрегат → осознанно обновляет промпт → прогоняет регрессию. Batch, а не автоматом на каждый клик.
|
||||||
|
|
||||||
|
## Что бы я ещё заложил в дизайн
|
||||||
|
|
||||||
|
- **Структурировать тип ошибки**, а не свободный текст: «пропущена строка» / «неверная цена» / «надо было UPDATE, а не ADD» / «ложный дубликат вместо UNRESOLVED». Тогда правки можно агрегировать и видеть системные слабости промпта, а не разбирать вручную.
|
||||||
|
- Фиксировать **версию промпта**, на которой получен результат — иначе через месяц непонятно, актуальна ли ещё ошибка.
|
||||||
|
- Учесть **конфиденциальность**: реальные договоры с реквизитами/ценами копятся в БД — проговорить с заказчиком.
|
||||||
|
|
||||||
|
Резюме: режим «загрузил → система выдала → юзер поправил → правка сохранилась» — да, делать стоит, это естественный сборщик эталонных данных и ловушка системных ошибок. Только «обучение» держать осознанным (batch-обновление промпта + регрессия), а не авто-инъекцией каждой правки.
|
||||||
|
|
||||||
|
Код не трогаю — это обсуждение. Скажешь «делай» — распишу конкретную реализацию.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ЧАСТЬ 3: Короткий вариант для заказчика
|
||||||
|
|
||||||
|
Вот короткий вариант для заказчика:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Этап опытной эксплуатации (обучение системы)**
|
||||||
|
|
||||||
|
На первое время предлагаем режим проверки: вы загружаете реальные документы, система выдаёт результат, а вы отмечаете ошибки — что распознано неверно (пропущена строка, неверная цена, неправильное сопоставление и т.п.).
|
||||||
|
|
||||||
|
Эти отметки **в обезличенном виде** (без названий компаний, ФИО, реквизитов и иных персональных данных) накапливаются в базе и используются для дальнейшей настройки и обучения системы.
|
||||||
|
|
||||||
|
Это позволит откалибровать сервис на ваших реальных договорах, а не на тестовых примерах, и системно повышать точность.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Если нужно ещё короче (одним абзацем) или, наоборот, добавить пункт про согласие на обработку данных — скажи, поправлю.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ЧАСТЬ 4: Ответ про привязку замечаний к строкам таблицы
|
||||||
|
|
||||||
|
Дай гляну, как сейчас выглядит вывод результата — чтобы предложить привязку замечаний к реальным элементам интерфейса, а не абстрактно.
|
||||||
|
|
||||||
|
Read file:///home/naeel/nubes/contracts/contractor/deploy/services
|
||||||
|
|
||||||
|
Read memory file:///home/naeel/.vscode-server/data/User/workspaceStorage/dc98ff3e658364e1826ee6585ebe4b5e/GitHub.copilot-chat/memory-tool/memories/repo/contracts-two-panels.md
|
||||||
|
|
||||||
|
Searched for regex `target_id|r1|new_row|action.*ADD|операц|spec|строк` (`**/contractor/view.cfm`), no results
|
||||||
|
|
||||||
|
Searched for files matching `**/contracts-app/site/templates/**`, no matches
|
||||||
|
|
||||||
|
Не «у каждого абзаца» — это привязка не к тому объекту. Результат работы системы — это не текст-простыня, а **таблица спецификации**: набор строк-услуг, каждая получена операцией LLM (`ADD`/`UPDATE`/`DELETE`/`UNRESOLVED`) и имеет свой id (`r1`, `r2`…). Замечание логично цеплять **к строке результата**, а не к абзацу исходника.
|
||||||
|
|
||||||
|
## Как я бы это сделал
|
||||||
|
|
||||||
|
**Уровень 1 — отметка на строке (основное).**
|
||||||
|
У каждой строки итоговой таблицы — маленькая иконка «⚠ ошибка». Клик открывает мини-форму с **выбором типа ошибки** (не свободный текст):
|
||||||
|
- неверная цена / объём / сумма
|
||||||
|
- неверное наименование
|
||||||
|
- лишняя строка (надо было удалить)
|
||||||
|
- пропущена строка (есть в документе — нет в результате)
|
||||||
|
- неверное сопоставление (надо UPDATE существующей, а не новая ADD; или наоборот)
|
||||||
|
- неверный тип ДС / режим (partial vs новая редакция)
|
||||||
|
|
||||||
|
Плюс необязательное поле «комментарий» свободным текстом.
|
||||||
|
|
||||||
|
**Уровень 2 — отметка «пропущено».**
|
||||||
|
Случай «строки вообще нет в результате» не покрывается иконкой на строке. Нужна отдельная кнопка «➕ Система пропустила позицию» над/под таблицей.
|
||||||
|
|
||||||
|
**Уровень 3 — общая оценка (опционально).**
|
||||||
|
Одна кнопка вверху: «Результат в целом верный / есть ошибки». Дёшево, но даёт быстрый сигнал по проценту удачных разборов.
|
||||||
|
|
||||||
|
## Почему так, а не поле у абзаца
|
||||||
|
|
||||||
|
- **Структурированный фидбек агрегируется.** «Тип ошибки = неверное сопоставление встречается в 40% ДС» → понятно, что чинить в промпте. Свободный текст у абзацев придётся разбирать вручную, аналитики ноль.
|
||||||
|
- **Замечание = дельта операции.** Юзер фактически правит строку → разница «что выдала LLM» vs «как должно быть» и есть обучающий сигнал. Это ложится на вашу событийную модель (apply_events.cfm) почти без натяжки.
|
||||||
|
- **Меньше шума.** Поле ввода под каждым абзацем визуально перегружает и провоцирует расплывчатые комментарии.
|
||||||
|
|
||||||
|
## Что копится в базе (обезличенно)
|
||||||
|
|
||||||
|
На каждое замечание:
|
||||||
|
- тип ошибки + (опц.) комментарий
|
||||||
|
- что выдала система по этой строке
|
||||||
|
- как поправил юзер (правильное значение)
|
||||||
|
- версия промпта
|
||||||
|
- **без** названий компаний/ФИО/реквизитов — только структура и числа
|
||||||
|
|
||||||
|
## Как это сформулировать заказчику (1 фраза)
|
||||||
|
|
||||||
|
> В режиме проверки у каждой строки результата будет отметка «ошибка» с выбором типа (неверная цена, пропущена позиция, неверное сопоставление и т.п.). Эти отметки в обезличенном виде копятся в базе и используются для настройки точности.
|
||||||
|
|
||||||
|
Хочешь — распишу конкретную реализацию (поля в БД, эндпоинт сохранения, изменения в шаблоне вывода). Код пока не трогаю, жду «делай».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ЧАСТЬ 5: Ответ про UI с примером на допник-1-XXX002
|
||||||
|
|
||||||
|
Отлично, теперь предметно. Твой вывод — это карточка документа + **таблица операций** (`Действие / Услуга / Цена / Кол-во / Сумма / Дата`). Замечания цепляем именно к строкам этой таблицы. Вот как это выглядело бы на твоём примере:
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ допник-1-XXX002-01200_3.docx — 2 оп., partial (14с) [✓ всё верно]
|
||||||
|
+2 ~0 -0
|
||||||
|
|
||||||
|
Действие Услуга Цена Кол-во Сумма Дата ⚠
|
||||||
|
ADD WAF: Positive Technologies, в составе… 104021.67 1 104021.67 2026-03-30 [⚠]
|
||||||
|
ADD Облачный диск Valo Cloud, в составе… 68700 1 68700 2026-02-01 [⚠]
|
||||||
|
|
||||||
|
[ ➕ Система пропустила позицию ]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Клик по `[⚠]` в строке** → раскрывается мини-форма прямо под строкой:
|
||||||
|
|
||||||
|
```
|
||||||
|
Тип ошибки: ( ) неверная цена/сумма
|
||||||
|
( ) неверное кол-во
|
||||||
|
( ) неверное наименование услуги
|
||||||
|
(•) лишняя строка — этой операции быть не должно
|
||||||
|
( ) неверное действие (должно быть UPDATE/DELETE, а не ADD)
|
||||||
|
( ) неверная дата
|
||||||
|
Правильное значение: [_____________] (необязательно)
|
||||||
|
Комментарий: [_____________] (необязательно)
|
||||||
|
[ Сохранить ]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Кнопка `[✓ всё верно]`** вверху карточки — если разбор документа корректен целиком. Один клик = положительный сигнал, ничего расписывать не надо.
|
||||||
|
|
||||||
|
**Кнопка `➕ Система пропустила позицию`** — для случая, которого нет в таблице (услуга была в документе, но LLM её не извлекла). Открывает форму ввода пропущенной строки.
|
||||||
|
|
||||||
|
## Что уходит в базу по одному замечанию (обезличенно)
|
||||||
|
|
||||||
|
На примере второй строки, если юзер укажет «неверная цена, правильно 68000»:
|
||||||
|
|
||||||
|
```
|
||||||
|
operation: ADD
|
||||||
|
service: "Облачный диск Valo Cloud, в составе…" ← наименование услуги, без названия клиента
|
||||||
|
field: price
|
||||||
|
llm_value: 68700
|
||||||
|
correct: 68000
|
||||||
|
error_type: wrong_price
|
||||||
|
prompt_version: v1.0.178
|
||||||
|
```
|
||||||
|
|
||||||
|
Никаких «ЗАО ХХХ002», № договора, ФИО — только структура услуги и числа.
|
||||||
|
|
||||||
|
## Почему так, а не поле под каждым абзацем
|
||||||
|
|
||||||
|
Твой реальный вывод подтверждает: это **таблица**, а не текст. Поле у «абзаца» некуда вешать — абзацев тут нет, есть операции. А привязка к операции даёт:
|
||||||
|
- агрегацию по типу ошибки (видно, что чинить в промпте),
|
||||||
|
- готовую дельту «LLM выдала X → правильно Y» как обучающий сигнал,
|
||||||
|
- ровную укладку в вашу событийную модель (apply_events.cfm).
|
||||||
|
|
||||||
|
Хочешь — распишу конкретику реализации (колонка в таблице вывода, эндпоинт сохранения, структура таблицы в БД). Код не трогаю, жду «делай».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ЧАСТЬ 6: ДЕТАЛЬНЫЙ ПЛАН — страница /teach
|
||||||
|
|
||||||
|
Это план, не правка кода — изучу структуру, чтобы план лёг на реальный код, а не в воздух. Читаю ключевые файлы.
|
||||||
|
|
||||||
|
Read file:///home/naeel/nubes/contracts/contractor/db.cfc
|
||||||
|
|
||||||
|
Read file:///home/naeel/nubes/contracts/contractor/Application.cfc
|
||||||
|
|
||||||
|
Read memory file:///home/naeel/.vscode-server/data/User/workspaceStorage/dc98ff3e658364e1826ee6585ebe4b5e/GitHub.copilot-chat/memory-tool/memories/repo/contracts-deploy.md
|
||||||
|
|
||||||
|
Read memory file:///home/naeel/.vscode-server/data/User/workspaceStorage/dc98ff3e658364e1826ee6585ebe4b5e/GitHub.copilot-chat/memory-tool/memories/repo/contracts-lucee-test.md
|
||||||
|
|
||||||
|
Изучил структуру. Lucee-приложение с файловой маршрутизацией (`teach.cfm` → `/teach`), PostgreSQL `baza`, есть событийная модель (`spec_events`, `spec_current`) и уже готовый read-only SQL-эндпоинт `/db/query?sql=…` в db.cfc — через него агент и будет читать БД. Вот детальный план.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# План: страница `/teach` (опросник + обучение)
|
||||||
|
|
||||||
|
## 1. Принцип изоляции — что НЕ трогаем
|
||||||
|
|
||||||
|
Маршрутизация в Lucee файловая → **новый путь = новый файл**, существующий код не редактируется вообще:
|
||||||
|
|
||||||
|
| Не трогаем | Добавляем |
|
||||||
|
|---|---|
|
||||||
|
| `api.cfm`, `process*.cfm`, `apply_events.cfm`, db.cfc, Application.cfc, `upload.cfm`, `view.cfm` | `teach.cfm`, `teach_api.cfm`, `teach_schema.cfm` |
|
||||||
|
| существующие таблицы (`spec_events`, `contracts`…) | **одна новая таблица** `feedback` |
|
||||||
|
|
||||||
|
Никаких `ALTER` существующих таблиц, никаких правок в `db.cfc/schema()`. Только чтение чужих таблиц + запись в свою.
|
||||||
|
|
||||||
|
## 2. Новые файлы (3 шт.)
|
||||||
|
|
||||||
|
1. **`teach_schema.cfm`** — одноразовый: `CREATE TABLE IF NOT EXISTS feedback (…)`. Зашёл по URL один раз → таблица создана.
|
||||||
|
2. **`teach.cfm`** — сама страница:
|
||||||
|
- список уже разобранных договоров/ДС (читает `contracts` + `supplements` + `spec_events`);
|
||||||
|
- выбрал ДС → рисует ту же таблицу операций (`Действие/Услуга/Цена/Кол-во/Сумма/Дата`), что в твоём примере, но с колонкой `⚠` и кнопками `[✓ всё верно]`, `[➕ пропущена позиция]`;
|
||||||
|
- JS отправляет замечание `POST`-ом на `teach_api.cfm`.
|
||||||
|
3. **`teach_api.cfm`** — приём замечания: **параметризованный** `INSERT` в `feedback` (никакого конкатенированного SQL — защита от инъекций). Возвращает JSON `{ok:true}`.
|
||||||
|
|
||||||
|
> Источник данных для таблицы операций — `spec_events` (`action`, `new_values` JSONB, `comment`, `seq`). При реализации сверим, что карточка «2 оп., partial» строится именно отсюда.
|
||||||
|
|
||||||
|
## 3. Схема таблицы `feedback` (ядро — продумано под анализ агентом)
|
||||||
|
|
||||||
|
Главные поля вынесены отдельными колонками (не в JSON), чтобы агент агрегировал простым SQL; значения — в JSONB.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
|
||||||
|
-- привязка (внутренняя трассировка, в обучающий экспорт НЕ идёт)
|
||||||
|
contract_id UUID, -- FK-логически на contracts, без жёсткого constraint
|
||||||
|
supplement_id UUID,
|
||||||
|
event_seq INTEGER, -- какая операция ДС (NULL для "пропущено"/"документ в целом")
|
||||||
|
|
||||||
|
-- уровень и вердикт
|
||||||
|
scope TEXT NOT NULL, -- 'row' | 'missed' | 'document'
|
||||||
|
verdict TEXT, -- 'correct' | 'error'
|
||||||
|
|
||||||
|
-- суть ошибки (для агрегации)
|
||||||
|
error_type TEXT, -- wrong_price|wrong_qty|wrong_name|wrong_date|
|
||||||
|
-- extra_row|missed_row|wrong_action|wrong_mode
|
||||||
|
field TEXT, -- price|qty|sum|name|date_start|action|mode
|
||||||
|
|
||||||
|
-- обучающий сигнал: что выдала система vs как правильно
|
||||||
|
service_name TEXT, -- наименование услуги (обезличено)
|
||||||
|
llm_value JSONB, -- что выдала LLM
|
||||||
|
correct_value JSONB, -- что указал юзер
|
||||||
|
|
||||||
|
-- контекст результата
|
||||||
|
prompt_version TEXT, -- версия промпта на момент разбора
|
||||||
|
doc_mode TEXT, -- partial | full_replace
|
||||||
|
comment TEXT, -- свободный комментарий юзера (необяз.)
|
||||||
|
reviewer TEXT -- обезличенный id сессии/юзера (необяз.)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feedback_error ON feedback(error_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feedback_prompt ON feedback(prompt_version);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Что пишется по каждому типу замечания
|
||||||
|
|
||||||
|
| Действие юзера | scope | verdict | error_type | llm_value → correct_value |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `[✓ всё верно]` на карточке | `document` | `correct` | — | — |
|
||||||
|
| `⚠` на строке: неверная цена | `row` | `error` | `wrong_price` | `{"price":68700}` → `{"price":68000}` |
|
||||||
|
| `⚠`: лишняя строка | `row` | `error` | `extra_row` | вся операция → `null` |
|
||||||
|
| `⚠`: должно быть UPDATE, а не ADD | `row` | `error` | `wrong_action` | `{"action":"ADD"}` → `{"action":"UPDATE"}` |
|
||||||
|
| `[➕ пропущена позиция]` | `missed` | `error` | `missed_row` | `null` → `{name,price,qty,sum,date}` |
|
||||||
|
| неверный режим ДС | `document` | `error` | `wrong_mode` | `{"mode":"partial"}` → `{"mode":"full_replace"}` |
|
||||||
|
|
||||||
|
Каждая запись самодостаточна: видно **что было** и **как надо** → готовая обучающая пара.
|
||||||
|
|
||||||
|
## 5. Обезличивание
|
||||||
|
|
||||||
|
- В `feedback` **не копируем** название клиента, № договора, ФИО, реквизиты.
|
||||||
|
- Храним только: `service_name` (тип услуги — «WAF Positive Technologies»), числа, тип ошибки, версию промпта.
|
||||||
|
- `contract_id/supplement_id` — это UUID (не имя), для внутренней трассировки. В **обучающий экспорт** агента эти id не включаются — только структурные поля.
|
||||||
|
- Для заказчика формулировка: *«в обезличенном виде, без названий компаний и персональных данных»* — соответствует фактической схеме.
|
||||||
|
|
||||||
|
## 6. Как агент читает и анализирует (потом)
|
||||||
|
|
||||||
|
Через уже существующий `/db/query?sql=…` (read-only) или напрямую psql. Примеры:
|
||||||
|
|
||||||
|
**Системные слабости промпта:**
|
||||||
|
```sql
|
||||||
|
SELECT error_type, field, count(*) AS n
|
||||||
|
FROM feedback WHERE verdict='error'
|
||||||
|
GROUP BY error_type, field ORDER BY n DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Качество по версии промпта (регрессия):**
|
||||||
|
```sql
|
||||||
|
SELECT prompt_version,
|
||||||
|
count(*) FILTER (WHERE verdict='correct') AS ok,
|
||||||
|
count(*) FILTER (WHERE verdict='error') AS err
|
||||||
|
FROM feedback GROUP BY prompt_version;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Выгрузка обучающих пар (для правки промпта/few-shot):**
|
||||||
|
```sql
|
||||||
|
SELECT service_name, error_type, llm_value, correct_value
|
||||||
|
FROM feedback WHERE verdict='error' AND scope='row';
|
||||||
|
```
|
||||||
|
|
||||||
|
Дальше агент: смотрит агрегат → предлагает правку промпта/глоссария → прогоняет на накопленных парах → сравнивает метрику до/после. **Никакой авто-инъекции** правок в промпт — только осознанное batch-обновление.
|
||||||
|
|
||||||
|
## 7. Версия промпта — нюанс
|
||||||
|
|
||||||
|
Сейчас результат разбора **не штампуется** версией промпта. Варианты:
|
||||||
|
- (минимум, без правки основного кода) `/teach` пишет в `prompt_version` **текущую активную** версию промпта на момент замечания — приблизительно, с оговоркой;
|
||||||
|
- (правильно, отдельной задачей позже) при разборе сохранять `prompt_version` в результат — но это уже касается основного пайплайна, делать отдельно и по «делай».
|
||||||
|
|
||||||
|
Флажок: на старте берём активную версию, точность привязки уточним позже.
|
||||||
|
|
||||||
|
## 8. Этапы внедрения
|
||||||
|
|
||||||
|
1. `teach_schema.cfm` → создать таблицу `feedback`.
|
||||||
|
2. `teach.cfm` (чтение разобранных ДС + рендер таблицы с `⚠`) — без сохранения, только UI.
|
||||||
|
3. `teach_api.cfm` (параметризованный INSERT) + подключить JS-отправку.
|
||||||
|
4. Проверка end-to-end на одном реальном ДС (как твой `допник-1-XXX002`).
|
||||||
|
5. 2–3 контрольных SQL-запроса агента → убедиться, что данные читаемы и агрегируются.
|
||||||
|
6. (позже, отдельно) первый цикл анализа → правка промпта → регрессия.
|
||||||
|
|
||||||
|
## 9. Открытые вопросы к тебе
|
||||||
|
|
||||||
|
1. **`/teach` работает только по уже разобранным** документам (из БД) — или нужна и загрузка-разбор прямо на странице? (загрузка-разбор = переиспользование основного пайплайна, сложнее).
|
||||||
|
2. **Доступ к `/teach`** — нужен ли пароль/ограничение, или dev-стенд открыт?
|
||||||
|
3. **`service_name`** — оставляем как есть (тип услуги) или дополнительно маскируем?
|
||||||
|
4. **Кто reviewer** — нужно различать разных проверяющих или достаточно анонимной сессии?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Это план, код не трогал. Скажешь по каким пунктам «делай» (и ответишь на вопросы из §9) — начну с `teach_schema.cfm`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ЧАСТЬ 7: Ответ про промпт для Опуса
|
||||||
|
|
||||||
|
Текст для Опуса хороший — чёткие требования и правильные ограничения (не трогать пайплайн, без online-training, обезличенность). Но в нынешнем виде Опус ответит **обобщённо**, потому что в промпте нет контекста вашего стека и текущей схемы. По каждому из 5 вопросов это критично. Что я бы добавил, прежде чем отправлять:
|
||||||
|
|
||||||
|
## Чего не хватает Опусу для предметного ответа
|
||||||
|
|
||||||
|
1. **Стек и маршрутизация.** Lucee/CFML 6.0, файловая маршрутизация (`teach.cfm` → `/teach`), PostgreSQL `baza`. Без этого Опус не поймёт, как «не трогать пайплайн» технически (а ответ — просто новый файл, без правок существующих).
|
||||||
|
|
||||||
|
2. **Текущая схема — особенно для Q3.** У вас уже есть событийная модель:
|
||||||
|
- `spec_events(contract_id, supplement_id, seq, action, target_hash, new_values JSONB, comment, status)`
|
||||||
|
- `spec_current`, `contracts`, `supplements`.
|
||||||
|
|
||||||
|
Связь с исходной операцией = `(contract_id, supplement_id, event_seq)`, и это **уже отвечает на Q3**: ничего в пайплайне трогать не надо, фидбек просто ссылается на `seq` логически, без FK-constraint. Опус должен это знать, иначе предложит схему, не совпадающую с вашей.
|
||||||
|
|
||||||
|
3. **Форма результата (для Q1/Q2).** Таблица операций `Действие / Услуга / Цена / Кол-во / Сумма / Дата`, режимы `partial | full_replace`. Дайте Опусу реальный пример вывода (твой `допник-1-XXX002`).
|
||||||
|
|
||||||
|
4. **Ключевой факт для Q4.** Сейчас результат разбора **не штампуется версией промпта** — этого в промпте нет, а это определяет весь ответ на Q4. Без упоминания Опус посоветует «просто сохраняйте prompt_version», не зная, что её сейчас негде взять, кроме «текущей активной».
|
||||||
|
|
||||||
|
5. **Как агент потом читает БД (для Q1).** Есть read-only эндпоинт `/db/query?sql=…` в db.cfc. Это аргумент за «плоские колонки `error_type/field/prompt_version` + значения в JSONB» — чтобы агрегировалось обычным SQL.
|
||||||
|
|
||||||
|
## Мелочи по формулировкам
|
||||||
|
|
||||||
|
- Q5 стоит разнести: отдельно **PII** (имена/реквизиты/№ договора — не хранить) и отдельно **изоляция рабочих данных** (никаких `ALTER`/FK на боевые таблицы, только своя `feedback`). Сейчас они слиты в один вопрос.
|
||||||
|
- Добавь явно: *«предложи минимальный набор `error_type` как enum-значения»* — иначе Опус может уйти в свободный текст ошибок, который потом не агрегируется.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Хочешь — могу либо (а) переписать твой промпт для Опуса с этим контекстом, либо (б) сам ответить на все 5 вопросов по вашей реальной схеме (у меня он есть). Скажи, что полезнее.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ЧАСТЬ 8: Финальный предметный ответ по 5 вопросам
|
||||||
|
|
||||||
|
Понял, отвечаю по существу — по вашей реальной схеме.
|
||||||
|
|
||||||
|
## 1. Минимальная схема `feedback` для MVP
|
||||||
|
|
||||||
|
Плоские колонки для агрегации + JSONB для значений:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
|
||||||
|
-- связь с исходной операцией (логическая, без FK)
|
||||||
|
contract_id UUID,
|
||||||
|
supplement_id UUID,
|
||||||
|
event_seq INTEGER, -- NULL для missed/document
|
||||||
|
|
||||||
|
scope TEXT NOT NULL, -- 'row' | 'missed' | 'document'
|
||||||
|
verdict TEXT NOT NULL, -- 'correct' | 'error'
|
||||||
|
error_type TEXT, -- enum-значения, см. ниже
|
||||||
|
field TEXT, -- price|qty|sum|name|date_start|action|mode
|
||||||
|
|
||||||
|
service_name TEXT, -- наименование услуги (обезличено)
|
||||||
|
llm_value JSONB, -- что выдала система
|
||||||
|
correct_value JSONB, -- как правильно
|
||||||
|
|
||||||
|
prompt_version TEXT,
|
||||||
|
doc_mode TEXT, -- partial | full_replace
|
||||||
|
comment TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feedback_error ON feedback(error_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feedback_prompt ON feedback(prompt_version);
|
||||||
|
```
|
||||||
|
|
||||||
|
Фиксированный набор `error_type` (enum как CHECK или просто документированный список):
|
||||||
|
`wrong_price, wrong_qty, wrong_name, wrong_date, extra_row, missed_row, wrong_action, wrong_mode`.
|
||||||
|
|
||||||
|
Всё. Ни истории версий записи, ни статусов модерации в MVP не нужно.
|
||||||
|
|
||||||
|
## 2. UI первой версии vs отложить
|
||||||
|
|
||||||
|
**В MVP:**
|
||||||
|
- колонка `⚠` у каждой строки таблицы операций → мини-форма: выбор `error_type` (радио) + опц. «правильное значение» + опц. комментарий;
|
||||||
|
- кнопка `[✓ всё верно]` на карточке ДС (дешёвый положительный сигнал, `scope=document, verdict=correct`);
|
||||||
|
- кнопка `[➕ пропущена позиция]` (`scope=missed`).
|
||||||
|
|
||||||
|
**Отложить:**
|
||||||
|
- инлайн-редактирование значений прямо в ячейке (пока хватает «правильное значение» в форме);
|
||||||
|
- дашборд статистики на самой странице (агрегация — задача агента через SQL, не UI);
|
||||||
|
- модерация/удаление чужих замечаний, роли, авторизация по пользователям;
|
||||||
|
- разбор-на-лету (загрузка нового документа) — работаем по уже разобранным из БД.
|
||||||
|
|
||||||
|
## 3. Связь с исходной операцией без вмешательства в pipeline
|
||||||
|
|
||||||
|
Ключ — **логическая ссылка `(contract_id, supplement_id, event_seq)`**, БЕЗ внешнего ключа на `spec_events`. У вас операции уже лежат в `spec_events(contract_id, supplement_id, seq, action, new_values…)` — `seq` стабилен в рамках договора. `feedback` просто хранит эти три значения как обычные поля.
|
||||||
|
|
||||||
|
Почему без FK:
|
||||||
|
- FK на `spec_events` = риск, что повторный разбор/перезапись событий уронит вставку фидбека или каскадом заденет данные — это и есть «вмешательство». Логическая ссылка развязывает таблицы полностью.
|
||||||
|
- `feedback` — отдельная таблица, ничего в боевых не меняем (никаких `ALTER`, никаких триггеров).
|
||||||
|
|
||||||
|
Если `seq` теоретически может переехать при reset/reparse — дополнительно дублируем в `llm_value` снимок операции (`action` + `new_values`), чтобы замечание оставалось интерпретируемым даже без живого `spec_events`. Этого достаточно.
|
||||||
|
|
||||||
|
## 4. prompt_version — хранить сразу, но честно
|
||||||
|
|
||||||
|
Хранить **да** — без неё нельзя мерить регрессию (стало лучше/хуже после правки промпта). Но: сейчас результат разбора версией промпта **не штампуется**, взять её из результата негде.
|
||||||
|
|
||||||
|
Поэтому для MVP, не трогая pipeline:
|
||||||
|
- `/teach` на момент сохранения замечания читает **текущую активную** версию промпта из БД (ту, что Lucee отдаёт как активную) и пишет в `prompt_version`.
|
||||||
|
- Это приближение: оно верно, если промпт не менялся между разбором и проверкой. Помечаем это как «версия на момент проверки», не «на момент разбора».
|
||||||
|
|
||||||
|
Правильная привязка (штамповать `prompt_version` в результат при разборе) — отдельная задача по основному пайплайну, делается позже и по явному решению, не в этом изолированном flow.
|
||||||
|
|
||||||
|
## 5. Что обязательно исключить
|
||||||
|
|
||||||
|
**PII / обезличивание — не хранить:**
|
||||||
|
- название/ИНН клиента, № договора, ФИО, подписантов, реквизиты, email/телефоны;
|
||||||
|
- оригинальный текст документа и байты файла.
|
||||||
|
|
||||||
|
Храним только тип услуги (`service_name`), числа, тип ошибки, версию промпта. `contract_id/supplement_id` — это UUID (не имена), для внутренней трассировки; в обучающий **экспорт** агента они не попадают.
|
||||||
|
|
||||||
|
**Изоляция рабочих данных — исключить из дизайна:**
|
||||||
|
- любые `ALTER` существующих таблиц, FK-constraint'ы на боевые таблицы, триггеры;
|
||||||
|
- запись в `spec_events / spec_current / contracts / supplements` — `/teach` только читает их, пишет исключительно в `feedback`;
|
||||||
|
- никакого generic-SQL write-эндпоинта на публичной странице — только параметризованный INSERT в `teach_api.cfm` (защита от инъекций).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Это ответ-проектирование, код не трогаю. Скажешь «делай» — начну с `teach_schema.cfm` (создание таблицы), затем `teach.cfm` и `teach_api.cfm`.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Анализ ответа Опуса — zip_source, UI-режимы, промпты
|
||||||
|
|
||||||
|
Дата: 26.06.2025 | Ответ на [opus-request-zip-plan.md](../Files/opus-request-zip-plan.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Оценка ответа в целом
|
||||||
|
|
||||||
|
**Качество: высокое.** Опус прочитал реальный код, разобрался в архитектуре, дал конкретные диффы по слоям. Не «размышления вообще», а точные строки и функции. 95% рекомендаций — правильные.
|
||||||
|
|
||||||
|
**Что упущено:**
|
||||||
|
- Lucee-слой (`upload.cfm`, `api.cfm`) — тоже участвует в upload, но Опус его не проанализировал
|
||||||
|
- `confidence` — предлагает сохранять в БД, но не говорит где именно брать (LLM возвращает? парсить из промпта?)
|
||||||
|
- Порог 100 файлов для «Потока» — спорный, обсудим ниже
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. По пунктам
|
||||||
|
|
||||||
|
### 2.1. `zip_source` — ✅ СОГЛАСЕН полностью
|
||||||
|
|
||||||
|
План по слоям правильный. Ключевые моменты:
|
||||||
|
|
||||||
|
- **`zip_source` не участвует в classify/group/compare** — верно. Чисто визуальный атрибут.
|
||||||
|
- **Формат: имя ZIP с расширением** — да, `«Ромашка.zip»`.
|
||||||
|
- **PK не трогаем (UUID)**, «ID = zip/filename» только для отображения — верно.
|
||||||
|
- **Дедупликация по паре `(zip_source, name)`** — ⚠️ самый критичный момент. Опус прав: если не сменить ключ, одноимённые файлы из разных ZIP будут перезаписываться. Но **надо проверить**: текущий код в `addRegularFile()` (files.js) ищет по `f.name`. При добавлении `zip_source` нужно либо:
|
||||||
|
- Ключ = `zip_source + "/" + filename` (как предлагает заказчик)
|
||||||
|
- Или ключ = `(zip_source || "") + filename`
|
||||||
|
|
||||||
|
Я за вариант с конкатенацией в одну строку — проще для сравнения.
|
||||||
|
|
||||||
|
- **unzip.py не трогаем** — верно. Имя ZIP уже есть на фронте (`file.name`).
|
||||||
|
|
||||||
|
### 2.2. Вариант отображения — ✅ СОГЛАСЕН (Вариант А)
|
||||||
|
|
||||||
|
Заголовок-секция ZIP + отступ `padding-left: 24px`.
|
||||||
|
- Просто, без нового состояния
|
||||||
|
- Соответствует тому что описал заказчик
|
||||||
|
- Опциональное сворачивание — да, но не в первой итерации
|
||||||
|
|
||||||
|
### 2.3. Два UI-режима — ⚠️ ЧАСТИЧНО СОГЛАСЕН
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- Бэкенд не меняется — правильно
|
||||||
|
- `state.ui.mode` — хорошее место
|
||||||
|
- Сводный отчёт («зелёное сворачиваем, красное показываем») — отличная идея
|
||||||
|
- Авто-определение + ручной override — разумно
|
||||||
|
|
||||||
|
**Спорные моменты:**
|
||||||
|
- **Порог 100 файлов** — слишком низкий для автоматического предложения. При 100 файлах текущий UI работает нормально (скролл, 50vh). Реальный болевой порог — **200-300+**. Предлагаю порог **200**.
|
||||||
|
- **«Поток» сейчас не нужен.** Если заказчик работает с 5-50 файлами, весь Stream Mode — оверинжиниринг. Но архитектурно заложить `state.ui.mode` — дёшево и правильно.
|
||||||
|
|
||||||
|
### 2.4. Промпты — ✅ СОГЛАСЕН, с уточнениями
|
||||||
|
|
||||||
|
#### Classify — проблемы А-Д:
|
||||||
|
|
||||||
|
**А. Counterparty / блок про стороны НУБЕС** — 🔴 КРИТИЧНАЯ.
|
||||||
|
Опус абсолютно прав. Промпт сейчас не говорит что НУБЕС = Исполнитель. LLM возвращает случайную сторону. Чинится одной вставкой в промпт. Делать **первым**.
|
||||||
|
|
||||||
|
НО: Опус предлагает «ИНН 7727... (взять у заказчика)». Это перебор. Достаточно:
|
||||||
|
```
|
||||||
|
НУБЕС известен как: «НУБЕС», «ООО НУБЕС», «ООО "НУБЕС"», «Nubes».
|
||||||
|
counterparty — ВСЕГДА вторая сторона, НИКОГДА не НУБЕС.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Б. parent_number у contract** — ✅ верно. `parent_number = null` для contract.
|
||||||
|
|
||||||
|
**В. Мусорные документы** — ✅ верно. Добавить примеры в `doc_type=other`.
|
||||||
|
|
||||||
|
**Г. Few-shot примеры в classify** — ✅ верно. 1-2 примера улучшат точность.
|
||||||
|
|
||||||
|
**Д. confidence** — ⚠️ спорно. Опус говорит «добавить колонку и показывать low в отчёте». Но `confidence` сейчас даже не сохраняется. Предлагаю **сначала убрать из промпта** (меньше путаницы), а потом, когда будет реальная потребность — добавить и колонку, и парсинг.
|
||||||
|
|
||||||
|
#### Compare (diff) — ✅ СОГЛАСЕН
|
||||||
|
|
||||||
|
- «UNRESOLVED вместо дубль-ADD при сомнении» — верно
|
||||||
|
- `temperature=0.1` для diff — проверить (скорее всего уже)
|
||||||
|
- `full_replace` → автоматическое удаление старых строк на стороне Python — **умная идея**, снижает нагрузку на LLM
|
||||||
|
|
||||||
|
#### Разные промпты под сценарии — ✅ СОГЛАСЕН
|
||||||
|
|
||||||
|
Не нужно. Один classify + один diff. Меньше рассинхрона.
|
||||||
|
|
||||||
|
### 2.5. Гомоглифы — ⚠️ ОСТОРОЖНО
|
||||||
|
|
||||||
|
Опус предлагает:
|
||||||
|
- `С/C → C` (латиница)
|
||||||
|
- `О/0` — «трактовать осторожно»
|
||||||
|
- `Ё → Е`
|
||||||
|
|
||||||
|
**Моё мнение:**
|
||||||
|
- `С→C` и `Ё→Е` — **опасно**. Это меняет семантику номера. `МЭС-123` ≠ `МЭC-123`. Лучше: **не заменять, а добавить второй проход сравнения** — если точное совпадение не найдено, попробовать с гомоглифами. Или нормализовать ОБА варианта (и кириллицу, и латиницу) к единому представлению, но сохранять оригинал для отображения.
|
||||||
|
- Конкретно для `Ё`: да, `Ё→Е` допустимо (в делопроизводстве Ё часто заменяют на Е). Но лучше сделать настраиваемым.
|
||||||
|
|
||||||
|
### 2.6. Приоритеты — ✅ СОГЛАСЕН с корректировкой
|
||||||
|
|
||||||
|
| Что | Приоритет Опуса | Моя оценка |
|
||||||
|
|-----|----------------|-----------|
|
||||||
|
| Counterparty в промпте | P0 | P0 ✅ |
|
||||||
|
| doc_type=other мусор | P0 | P0 ✅ |
|
||||||
|
| Гомоглифы | P0 | P1 ⚠️ (осторожно, не ломать) |
|
||||||
|
| zip_source | P1 | P1 ✅ |
|
||||||
|
| Группировка по ZIP в UI | P1 | P1 ✅ |
|
||||||
|
| Режим «Поток» | P2 | P3 (отложить, нет потребности) |
|
||||||
|
| confidence в БД | P3 | P3 (или убрать из промпта) |
|
||||||
|
| diff-UNRESOLVED | P3 | P2 (дёшево, большой эффект) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Что Опус упустил
|
||||||
|
|
||||||
|
### 3.1. Lucee-слой
|
||||||
|
|
||||||
|
`upload.cfm` и `api.cfm` на Lucee тоже обрабатывают загрузку. Если файл идёт через Lucee (а не напрямую на VM), `zip_source` нужно прокинуть и там. Надо проверить — идёт ли upload через Lucee или напрямую на VM.
|
||||||
|
|
||||||
|
**Факт:** судя по `index.cfm`, JS грузится с VM (`contracts.kube5s.ru/static/app.js`), а upload идёт на `VM_API + '/upload'`. Значит Lucee в upload **не участвует**. `zip_source` в Lucee не нужен. Опус оказался прав молча.
|
||||||
|
|
||||||
|
### 3.2. Промпты в БД vs хардкод
|
||||||
|
|
||||||
|
Опус верно заметил: «промпты берутся из БД (Lucee), fallback — хардкод. Менять надо в БД через интерфейс промптов». Это **критично важно для исполнителя**: если просто поправить `FALLBACK_EXTRACT`/`FALLBACK_DIFF` в `llm_prompt.py` — в проде ничего не изменится, потому что используется версия из БД.
|
||||||
|
|
||||||
|
**Порядок правки промптов:**
|
||||||
|
1. Сначала в БД через UI (`/prompt.cfm`)
|
||||||
|
2. Потом в хардкоде (для fallback)
|
||||||
|
|
||||||
|
### 3.3. Порог для «Потока»
|
||||||
|
|
||||||
|
100 файлов — слишком консервативно. Таблица с `max-height: 50vh` и `overflow-y: auto` нормально работает при 100-150 файлах. Предлагаю **200** как порог для автопредложения. Но лучше — **сделать настраиваемым** (константа в начале app.js).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Итоговое мнение
|
||||||
|
|
||||||
|
**Ответ Опуса — хороший план.** 95% рекомендаций принимаю.
|
||||||
|
|
||||||
|
**Что делаем прямо сейчас (P0):**
|
||||||
|
1. Правка classify-промпта: блок про стороны НУБЕС + parent_number=null + примеры мусора
|
||||||
|
2. Правка diff-промпта: UNRESOLVED вместо дубль-ADD, temperature проверка
|
||||||
|
|
||||||
|
**Что делаем дальше (P1):**
|
||||||
|
3. `zip_source` сквозь все слои (БД → Python → JS)
|
||||||
|
4. Группировка по ZIP в таблице (Вариант А)
|
||||||
|
5. Дедупликация по `(zip_source, filename)`
|
||||||
|
|
||||||
|
**Что откладываем:**
|
||||||
|
6. Режим «Поток» — пока нет потребности
|
||||||
|
7. Гомоглифы — нужно больше примеров от заказчика
|
||||||
|
8. confidence — убрать из промпта, вернуть когда будет нужно
|
||||||
|
|
||||||
|
**Главный риск (ещё раз):** дедупликация в `addRegularFile()`. Без правки ключа на `(zip_source, name)` — фича сломается на первом же случае одинаковых имён в разных ZIP.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Sonnet Analysis — 2026-06-23 — app.js bugs
|
||||||
|
|
||||||
|
## Q1: Что сломано в app.js
|
||||||
|
Нужно убрать **ДВЕ** строки из app.js (не только `</script>`):
|
||||||
|
- строка 644: `</script>` — SyntaxError
|
||||||
|
- строка 845 старого index.cfm = `</body>` — вторая SyntaxError после первой
|
||||||
|
|
||||||
|
Обе попали из-за `sed -n '209,846p'`.
|
||||||
|
|
||||||
|
## Q2: lucide.createIcons()
|
||||||
|
**НЕ потерян.** В старом index.cfm был на строке 845 (перед `</script>`), попал в app.js. Также вызывается ещё в 4 местах внутри JS.
|
||||||
|
|
||||||
|
## Q3: Дубликаты var
|
||||||
|
`var UNZIP_URL` дублируется (стр. 6 и 7 app.js). Значения идентичны. `var` в JS допускает повторное объявление — не проблема.
|
||||||
|
`UPLOAD_URL` и `CONVERT_URL` НЕ дублируются — они на строках 207-208 старого cfm, sed начат с 209.
|
||||||
|
|
||||||
|
## Q4: CORS
|
||||||
|
`<script src>` **не требует CORS** — браузер грузит скрипты без проверки Origin.
|
||||||
|
fetch/XHR из app.js → contracts.kube5s.ru требуют CORS — Python отвечает `Access-Control-Allow-Origin: *` (OK).
|
||||||
|
|
||||||
|
## Q5: Скрытый баг — parseInfo в модале
|
||||||
|
Строки 484-499 app.js — правая панель "Распарсено" использует UPPERCASE ключи:
|
||||||
|
```javascript
|
||||||
|
d.ELEMENT_COUNT, d.PARAGRAPHS, d.TABLES, d.TABLE_ROWS,
|
||||||
|
d.PAGES, d.PARSE_TIME_MS, d.TEXT_LENGTH, d.ERRORS
|
||||||
|
```
|
||||||
|
Python возвращает lowercase: `element_count`, `elements` (без PARAGRAPHS/TABLES).
|
||||||
|
**Все числа в правой панели модала — undefined → 0.** Нужно исправить на lowercase.
|
||||||
|
|
||||||
|
## Q6: prompt.cfm и chat.cfm
|
||||||
|
Ходят на Lucee-домен (relative URL). Ответы ожидаются в uppercase (`d.OK`, `d.BODY` и т.д.) — корректно для CFML. Работает, если prompt.cfm/chat.cfm есть на Lucee.
|
||||||
|
|
||||||
|
## Приоритеты исправлений
|
||||||
|
1. Убрать `</script>` + `</body>` из app.js → JS выполнится
|
||||||
|
2. `parseInfo` поля: UPPERCASE → lowercase для корректной работы модала
|
||||||
|
3. prompt.cfm/chat.cfm — пока OK, потом перенести на VM
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
# Аудит для Sonnet — пофайловый разбор
|
||||||
|
|
||||||
|
Ты должен прочитать КАЖДЫЙ файл из списка ниже и выдать по нему детальный отчёт.
|
||||||
|
|
||||||
|
## Формат отчёта ПОФАЙЛОВО
|
||||||
|
|
||||||
|
Для каждого файла:
|
||||||
|
```
|
||||||
|
### convert_server.py
|
||||||
|
| Строка | Тип | Серьёзность | Что не так | Как исправить |
|
||||||
|
|--------|-----|-------------|------------|---------------|
|
||||||
|
| 242 | dead code | low | Дубликат _handle_cleanup | Удалить второй |
|
||||||
|
```
|
||||||
|
|
||||||
|
После пофайлового разбора — СВОДНАЯ ТАБЛИЦА всех проблем по серьёзности: critical → high → medium → low.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 1: `contractor/deploy/convert_server.py`
|
||||||
|
|
||||||
|
**Что это:** HTTP роутер на http.server + ThreadingMixIn. Все endpoint'ы приложения.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- ВСЕ `do_GET`, `do_POST`, `do_DELETE` — правильная диспетчеризация? Нет мёртвых путей?
|
||||||
|
- `_handle_process_v2` — валидация UUID? SSE корректно закрывается при ошибке? Утечка соединений?
|
||||||
|
- `_handle_upload` — размер тела? Content-Type проверка? Таумаут?
|
||||||
|
- `_handle_cleanup` — ДВА определения (строки ~242 и ~255). Второй перекрывает первый. Dead code. Порядок DELETE правильный?
|
||||||
|
- `_handle_api_sync` — читает JSON body. Что если тело пустое/битое? Что если keep_ids содержит 10000 id? Нет лимита.
|
||||||
|
- `_handle_api_document` — doc_id из URL без валидации UUID → psycopg2.InvalidTextRepresentation на "fake-id".
|
||||||
|
- `_handle_api_document_delete` — cascade порядок: spec_current → spec_events → supplements → document. Правильный?
|
||||||
|
- `_handle_classify_batch` — batch_id из JSON без валидации UUID.
|
||||||
|
- `_handle_apply_groups` — валидация структуры groups?
|
||||||
|
- `_handle_api_prompts_*` — role из query params без валидации.
|
||||||
|
- `_json()` — экранирует ли спецсимволы в данных?
|
||||||
|
- `_sse()` — f-string с json.dumps. Данные от LLM могут содержать спецсимволы.
|
||||||
|
- Импорт `execute` на строке 12 — не конфликтует с другими импортами?
|
||||||
|
- `ALTER TABLE ADD COLUMN IF NOT EXISTS` — права на DDL? Идемпотентно?
|
||||||
|
- CORS — `_send_cors()` вызывается везде? OPTIONS?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 2: `contractor/deploy/app.js`
|
||||||
|
|
||||||
|
**Что это:** Весь фронтенд (42KB). Загрузка, классификация, группы, сравнение, промпты.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- `fileQueue`, `contractId`, `batchId` — глобальные. Где расходятся с сервером?
|
||||||
|
- `renderTable()` — `innerHTML` из `fileQueue[].name`, `fileQueue[].status`. XSS?
|
||||||
|
- `fileInput change` — гонка удаления старых + upload новых?
|
||||||
|
- `syncDB()` — fire-and-forget, без await, без проверки ответа.
|
||||||
|
- `runClassify()` — утечка таймеров при ошибке?
|
||||||
|
- `loadGroups()` — ЕДИНСТВЕННОЕ место где diffBody. Больше никто не должен.
|
||||||
|
- `runCompareForGroup()` — compareCard. Все ссылки compareBody/compareStatus?
|
||||||
|
- `llmBtn` — compareCard. То же.
|
||||||
|
- SSE handler'ы — ДВА почти идентичных. Дублирование.
|
||||||
|
- `showText()` — `escHtml(classify_raw)` ок, но `counterparty`, `own_number` — НЕ экранированы! XSS.
|
||||||
|
- `escHtml()` — экранирует `<>&"'`?
|
||||||
|
- `stepDone/Active/resetStepper` — innerHTML через replace. Спецсимволы в id?
|
||||||
|
- `window._groupsData` — устаревает после remove→re-classify. runCompareForGroup использует индекс.
|
||||||
|
- `showClassifyBtn()` — идемпотентно?
|
||||||
|
- `lucide.createIcons()` — не ломает onclick?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 3: `contractor/deploy/app_utils.js`
|
||||||
|
|
||||||
|
**Что это:** Утилиты: форматирование, removeFile, escHtml, модалки.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- `removeFile()` — syncDB+resetStepper+showClassifyBtn через typeof. Если нет — молча.
|
||||||
|
- `formatDate()`, `formatSize()` — null/undefined safe?
|
||||||
|
- `escHtml()` — все опасные символы: `< > & " '`?
|
||||||
|
- `moveUp/Down` — не вызывают syncDB (правильно, состав не меняется).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 4: `contractor/deploy/services/classify.py`
|
||||||
|
|
||||||
|
**Что это:** LLM-классификация. ThreadPoolExecutor, выжимка, вызов LLM.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- `classify_batch()` — reset + list_pending. Гонка между ними?
|
||||||
|
- `_classify_one()` — `_smart_extract` может упасть до LLM. Обрабатывается?
|
||||||
|
- `_smart_extract()` — неожиданная структура elements_json?
|
||||||
|
- `_call_llm_classify()` — httpx `verify=False`. MITM уязвимость.
|
||||||
|
- `_safe_json_parse()` — edge cases: Null/None, числа без кавычек, пустой ответ.
|
||||||
|
- `MAX_WORKERS=4` — 4 одновременных запроса создают каждый свой пул?
|
||||||
|
- `LLM_KEY` из env — если не задан?
|
||||||
|
- API key в коде? (берётся из env, ок)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 5: `contractor/deploy/services/grouping.py`
|
||||||
|
|
||||||
|
**Что это:** Группировка документов по контрактам + виртуальные группы.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- `group_documents()` — виртуальные группы: что если parent_number и own_number оба null?
|
||||||
|
- Коллизия normalize_number: разные номера → одинаковый нормализованный.
|
||||||
|
- Сортировка по doc_date: None у всех → нестабильный порядок.
|
||||||
|
- `apply_groups()` — нет проверки на существующий contract (дубликат при повторе).
|
||||||
|
- `supplements_list.remove(s)` внутри цикла for — может пропускать элементы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 6: `contractor/deploy/services/process.py`
|
||||||
|
|
||||||
|
**Что это:** SSE пайплайн сравнения.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- `run_pipeline()` — битая ссылка supplement→document? timeout? retry?
|
||||||
|
- `call_llm()` — таймаут?
|
||||||
|
- SSE события — все ли обрабатываются на фронте?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 7: `contractor/deploy/services/parse.py`
|
||||||
|
|
||||||
|
**Что это:** Парсинг PDF/DOCX.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- Расширение: .PDF uppercase? Без расширения?
|
||||||
|
- PDF: битый/зашифрованный → исключение?
|
||||||
|
- DOCX: .doc (OLE) → исключение?
|
||||||
|
- Таблицы: пустые, объединённые ячейки?
|
||||||
|
- file_data = None?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 8: `contractor/deploy/services/upload.py`
|
||||||
|
|
||||||
|
**Что это:** Multipart загрузка через cgi.FieldStorage.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- cgi.FieldStorage deprecated. Большие файлы?
|
||||||
|
- content_length — отрицательное/огромное → rfile.read?
|
||||||
|
- filename — path traversal (`../../etc/passwd`)?
|
||||||
|
- 100MB файл → весь в памяти.
|
||||||
|
- contract_id без валидации → SQL.
|
||||||
|
- `delete_by_document` до создания нового → исключение = потеря старого без создания нового.
|
||||||
|
- base64 → +33% размер.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 9: `contractor/deploy/db/connection.py`
|
||||||
|
|
||||||
|
**Что это:** ThreadedConnectionPool.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- `DB_PASS` из env, пустой → ошибка подключения.
|
||||||
|
- `minconn=1, maxconn=10` — достаточно?
|
||||||
|
- getconn/putconn всегда в finally?
|
||||||
|
- `_connection` глобальная — сервер не стартует без БД.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 10: `contractor/deploy/db/documents.py`
|
||||||
|
|
||||||
|
**Что это:** CRUD documents + classify_raw.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- `insert()` — все параметры через %s?
|
||||||
|
- `set_classification()` — classify_raw=None → NULL.
|
||||||
|
- `set_classify_failed()` — не чистит старые поля классификации.
|
||||||
|
- `delete()` — без cascade!
|
||||||
|
- `get()` — SELECT *, может вернуть base64 original_bytes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 11: `contractor/deploy/db/supplements.py`
|
||||||
|
|
||||||
|
**Что это:** CRUD supplements + cascade.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- `delete_by_document()` — spec_current WHERE contract_id удаляет ВСЁ для контракта. Если несколько supplements → остальные теряют spec_current.
|
||||||
|
- `list_by_contract()` — orphan supplements игнорируются.
|
||||||
|
- `insert()` — нет FK проверки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 12: `contractor/deploy/db/contracts.py`
|
||||||
|
|
||||||
|
**Что это:** CRUD contracts.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- `insert()` — нет уникальности, можно дубликат.
|
||||||
|
- `delete_orphaned()` — вызывается? Где?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 13: `contractor/deploy/db/spec_events.py`
|
||||||
|
|
||||||
|
**Что это:** Event Sourcing.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- `get_next_seq()` — `MAX(seq)+1`. Гонка! Два потока → одинаковый seq.
|
||||||
|
- `reset()` — необратимо.
|
||||||
|
- `apply_ops()` — валидация структуры?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 14: `contractor/deploy/db/spec_current.py`
|
||||||
|
|
||||||
|
**Что это:** Текущая спецификация.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- Кто обновляет spec_current после удаления spec_events?
|
||||||
|
- `get_elements_json()` — где используется?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 15: `contractor/deploy/db/prompts.py`
|
||||||
|
|
||||||
|
**Что это:** CRUD промптов с версионированием.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- `seed_defaults()` — `if count>0: return` — если есть extract но нет classify → classify не создастся.
|
||||||
|
- `_ensure_classify_prompt()` — отдельный механизм, почему?
|
||||||
|
- `save_new_version()` — UPDATE+INSERT не атомарно. Гонка.
|
||||||
|
- `activate()` — гонка.
|
||||||
|
- `delete_prompt()` — проверка is_active потом DELETE. Гонка.
|
||||||
|
- `_serialize()` — мутирует словарь.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 16: `contractor/deploy/llm_prompt.py`
|
||||||
|
|
||||||
|
**Что это:** Билдер промптов.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- `build_prompt()` — f-string с данными парсинга. Безопасно?
|
||||||
|
- `_fetch_prompt()` — HTTP к Lucee. Таймаут? Fallback при недоступности?
|
||||||
|
- `build_classify_prompt()` — прямой доступ к БД, не через HTTP. Почему?
|
||||||
|
- FALLBACK_* хардкод — дублирование с БД.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файл 17: `contractor/index.cfm`
|
||||||
|
|
||||||
|
**Что это:** HTML-оболочка.
|
||||||
|
|
||||||
|
**Что смотреть:**
|
||||||
|
- `?v=1.0.175` хардкод — менять при каждом обновлении JS.
|
||||||
|
- pipelineStepper — ○⏳✓ через replace. Надёжно?
|
||||||
|
- XSS через prompt body в textarea/div?
|
||||||
|
- Z-index конфликты модалок?
|
||||||
|
- inline onclick — ломаются при перезагрузке JS?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## СВОДНАЯ ТАБЛИЦА (выдать после пофайлового разбора)
|
||||||
|
|
||||||
|
| # | Файл:строка | Серьёзность | Тип | Описание | Как исправить |
|
||||||
|
|---|-------------|-------------|-----|----------|---------------|
|
||||||
|
|
||||||
|
## ТОП-5 (срочно)
|
||||||
|
|
||||||
|
5 проблем, которые надо чинить прямо сейчас.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Аудит Sonnet — результаты
|
||||||
|
|
||||||
|
## Ключевые цифры
|
||||||
|
|
||||||
|
- **48 проблем** найдено
|
||||||
|
- **14 CRITICAL**, **15 HIGH**, **19 MEDIUM**
|
||||||
|
- 17 файлов проанализировано
|
||||||
|
|
||||||
|
## ТОП-5 срочных
|
||||||
|
|
||||||
|
1. **upload.py:28** — Path Traversal: `filename` без `os.path.basename()` → `../../etc/passwd`
|
||||||
|
2. **app.js:79,495-520,497,703,708** — XSS × 5 мест: `innerHTML` без `escHtml()` на данных от LLM
|
||||||
|
3. **grouping.py:76** — `supplements_list.remove(s)` в итерации → пропуск элементов
|
||||||
|
4. **spec_events.py:15** — `MAX(seq)+1` без блокировки → race condition на seq
|
||||||
|
5. **prompts.py:40,73,86** — 3 race conditions: seed/save/activate без транзакций
|
||||||
|
|
||||||
|
## Что я понял
|
||||||
|
|
||||||
|
### Мои косяки (надо чинить)
|
||||||
|
|
||||||
|
1. **XSS в 5 местах** — `escHtml` не везде. `counterparty`, `own_number`, `filename`, `contract_number` — всё от LLM, всё в innerHTML без экранирования. Тупо пропустил.
|
||||||
|
|
||||||
|
2. **`supplements_list.remove(s)` в цикле** — реальный баг в grouping.py:76. При удалении элемента из списка во время итерации for пропускаются элементы. Может ломать группировку.
|
||||||
|
|
||||||
|
3. **`syncDB()` без await** — fire-and-forget. Если сервер не ответил — не узнаем. БД рассинхронится с таблицей.
|
||||||
|
|
||||||
|
4. **`delete_by_document` удаляет spec_current для ВСЕГО контракта** — если у контракта 3 supplements, удаление одного затирает spec_current для двух других. Серьёзный баг в supplements.py.
|
||||||
|
|
||||||
|
5. **`verify=False` в httpx** — отключена проверка SSL. MITM-уязвимость в classify.py и llm_prompt.py.
|
||||||
|
|
||||||
|
### Что НЕ надо чинить (не критично)
|
||||||
|
|
||||||
|
- Dead code (дубликат `_handle_cleanup`) — не влияет на работу.
|
||||||
|
- `_serialize` мутирует словарь — косметика.
|
||||||
|
- `v1.0.175` хардкод — пока сойдёт.
|
||||||
|
- `.doc` без fallback — формат редкость.
|
||||||
|
- `errors="ignore"` в парсинге — мелочь.
|
||||||
|
|
||||||
|
### Что Sonnet нашёл сверх моего анализа
|
||||||
|
|
||||||
|
- `MAX(seq)+1` race condition — я не подумал про параллельные запросы к spec_events.
|
||||||
|
- `apply_groups()` без транзакций — я не проверил атомарность.
|
||||||
|
- `_safe_json_parse()` возвращает None для `"null"` — edge case который я упустил.
|
||||||
|
- DoS через `keep_ids` без лимита — не подумал про O(N²).
|
||||||
|
- `get()` возвращает base64 original_bytes (133MB) — утечка памяти.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Sonnet Analysis — 2026-06-24 — Auto-classification
|
||||||
|
|
||||||
|
## 1. Архитектура: отдельный /api/classify
|
||||||
|
Не встраивать в upload. Отдельный эндпоинт → фоновая классификация → SSE прогресс → UI подтверждение.
|
||||||
|
|
||||||
|
## 2. Промпт classify
|
||||||
|
Только header (первые 50 элементов / 2000 символов). Экономия токенов в 5-10 раз.
|
||||||
|
Поля: doc_type, contract_number, doc_date, counterparty, parent_contract_number.
|
||||||
|
|
||||||
|
## 3. Группировка
|
||||||
|
Новая таблица doc_classifications (staging). Группировка по (contract_number, counterparty) + fuzzy match.
|
||||||
|
Существующих contracts + supplements достаточно для хранения итога.
|
||||||
|
|
||||||
|
## 4. Схема БД
|
||||||
|
- ALTER supplements ADD sort_order
|
||||||
|
- CREATE TABLE doc_classifications (staging)
|
||||||
|
|
||||||
|
## 5. UI
|
||||||
|
Карточки групп (contract), внутри — сортированный список документов.
|
||||||
|
Действия: перенести, изменить тип, подтвердить, запустить сравнение.
|
||||||
|
|
||||||
|
## 6. Массовая загрузка
|
||||||
|
ZIP → batch upload → 5 параллельных воркеров (threading.Thread + queue.Queue).
|
||||||
|
2000 файлов × 6s / 5 воркеров ≈ 40 минут.
|
||||||
|
Память: BYTEA в PG для пилота ОК, для прода нужен S3.
|
||||||
|
|
||||||
|
## 7. Приоритеты
|
||||||
|
1. doc_classifications + промпт classify — низкая сложность, критично
|
||||||
|
2. /api/classify (один doc_id) — низкая, критично
|
||||||
|
3. Batch ZIP + workers + SSE — высокая, критично
|
||||||
|
4. Алгоритм группировки — средняя, критично
|
||||||
|
5. UI review — средняя, важно
|
||||||
|
6. /process-v2 per group — низкая, важно
|
||||||
|
7. Артикул→каталог — высокая, отложить
|
||||||
|
|
||||||
|
## Риски
|
||||||
|
- threading в http.server: на пилоте ОК, для прода нужен gunicorn
|
||||||
|
- Промпт classify надо обкатать на реальных документах ДО реализации
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
# План миграции: ВМ → Flask (полный перенос, без ВМ)
|
||||||
|
|
||||||
|
**Дата:** 2026-07-14
|
||||||
|
**Цель:** Убрать `deploy/convert_server.py` и весь ВМ-слой. Вся логика — во Flask.
|
||||||
|
**Принцип:** НИКАКОГО монолита. Каждый слой — отдельный модуль/blueprint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Аудит: насколько код уже decoupled
|
||||||
|
|
||||||
|
### ✅ УЖЕ ГОТОВО (можно брать как есть)
|
||||||
|
|
||||||
|
| Модуль | Строк | Статус | Почему |
|
||||||
|
|--------|-------|--------|--------|
|
||||||
|
| `db/connection.py` | ~60 | ✅ Идеально | Connection pool, ноль зависимостей |
|
||||||
|
| `db/documents.py` | ~100 | ✅ Идеально | Чистый CRUD, только `query()/execute()` |
|
||||||
|
| `db/contracts.py` | ~25 | ✅ Идеально | Чистый CRUD |
|
||||||
|
| `db/supplements.py` | ~60 | ✅ Идеально | Чистый CRUD |
|
||||||
|
| `db/spec_current.py` | ~20 | ✅ Идеально | Чистые запросы |
|
||||||
|
| `db/spec_events.py` | ~30 | ✅ Идеально | Чистый CRUD |
|
||||||
|
| `db/prompts.py` | ~80 | ✅ Идеально | Чистый CRUD + seed |
|
||||||
|
| `compare/parse.py` | ~100 | ✅ Идеально | Чистая функция: `(filename, bytes) → dict` |
|
||||||
|
| `compare/llm_client.py` | ~80 | ✅ Идеально | Protocol + Httpx + Fake, DI-ready |
|
||||||
|
| `compare/grouping.py` | ~160 | ✅ Идеально | Чистая логика: `batch_id → groups`, нет HTTP |
|
||||||
|
| `compare/llm.py` | ~40 | ✅ Хорошо | Уже принимает `llm_client` опционально (DI) |
|
||||||
|
| `llm_prompt.py` | ~80 | ✅ Идеально | Чистые функции сборки промптов |
|
||||||
|
| `repository.py` | ~120 | ✅ Хорошо | Protocol есть, PgRepository частично реализован |
|
||||||
|
|
||||||
|
### 🔧 НУЖНА АДАПТАЦИЯ (логика готова, интерфейс — нет)
|
||||||
|
|
||||||
|
| Модуль | Проблема | Что сделать |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `compare/upload.py` | Использует `cgi.FieldStorage` | Заменить на `request.files` из Flask |
|
||||||
|
| `compare/unzip.py` | Читает `rfile.read()` сырые байты | Заменить на `request.files` / `request.data` |
|
||||||
|
| `compare/classify.py` | `ThreadPoolExecutor` ок, но запускается из HTTP-метода | Обернуть в Flask background task (см. ниже) |
|
||||||
|
| `compare/process.py` | SSE через `self.wfile.write()` | Заменить на `Response(stream_with_context(...))` |
|
||||||
|
|
||||||
|
### ❌ НУЖНО ПЕРЕПИСАТЬ
|
||||||
|
|
||||||
|
| Модуль | Проблема | Что сделать |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `convert_server.py` | Монолит ~500 строк, все роуты в одном классе | Разобрать на Flask blueprints |
|
||||||
|
| `classify_worker.py` | subprocess.Popen — не нужно во Flask | Убрать, classify в отдельном потоке/процессе через `@app.route` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Целевая архитектура Flask
|
||||||
|
|
||||||
|
```
|
||||||
|
contracts-flask/
|
||||||
|
├── site/
|
||||||
|
│ ├── app.py # create_app(), регистрация blueprints
|
||||||
|
│ ├── config.py # Настройки (DB, LLM, лимиты)
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── __init__.py # register_routes(app)
|
||||||
|
│ │ ├── upload_bp.py # POST /upload, /convert-doc, /unzip-upload
|
||||||
|
│ │ ├── pipeline_bp.py # GET /process-v2 (SSE), POST /classify-batch
|
||||||
|
│ │ ├── api_bp.py # GET/POST /api/* (groups, documents, supplements, sync, cleanup)
|
||||||
|
│ │ ├── prompts_bp.py # GET/POST /api/prompts/*
|
||||||
|
│ │ ├── health_bp.py # GET /health
|
||||||
|
│ │ └── pages_bp.py # GET /, /architect (HTML-страницы)
|
||||||
|
│ ├── services/ # Бизнес-логика (перенос из deploy/compare/)
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── parse.py # ← deploy/compare/parse.py
|
||||||
|
│ │ ├── classify.py # ← deploy/compare/classify.py
|
||||||
|
│ │ ├── grouping.py # ← deploy/compare/grouping.py
|
||||||
|
│ │ ├── llm.py # ← deploy/compare/llm.py
|
||||||
|
│ │ ├── llm_client.py # ← deploy/compare/llm_client.py
|
||||||
|
│ │ └── metrics.py # ← deploy/compare/metrics.py
|
||||||
|
│ ├── db/ # Перенос из deploy/db/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── connection.py # ← deploy/db/connection.py
|
||||||
|
│ │ ├── documents.py # ← deploy/db/documents.py
|
||||||
|
│ │ ├── contracts.py # ← deploy/db/contracts.py
|
||||||
|
│ │ ├── supplements.py # ← deploy/db/supplements.py
|
||||||
|
│ │ ├── spec_current.py # ← deploy/db/spec_current.py
|
||||||
|
│ │ ├── spec_events.py # ← deploy/db/spec_events.py
|
||||||
|
│ │ └── prompts.py # ← deploy/db/prompts.py
|
||||||
|
│ ├── repository.py # ← deploy/repository.py (расширить)
|
||||||
|
│ ├── llm_prompt.py # ← deploy/llm_prompt.py
|
||||||
|
│ ├── templates/
|
||||||
|
│ │ └── index.html # УЖЕ ЕСТЬ, почти не меняется
|
||||||
|
│ └── static/ # JS-файлы: state.js, app_utils.js, files.js, groups.js, compare.js, app.js
|
||||||
|
├── requirements.txt # Flask + psycopg2 + httpx + pdfplumber + python-docx
|
||||||
|
├── Dockerfile
|
||||||
|
└── deploy/ # ОСТАЁТСЯ только:
|
||||||
|
├── nginx-contracts.conf
|
||||||
|
├── sync.sh
|
||||||
|
└── convert_doc.py # libreoffice-конвертер (stdin→stdout, НЕ HTTP)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ключевое правило: ZERO монолита
|
||||||
|
|
||||||
|
- **Blueprints** — каждый на ≤100 строк, одна ответственность
|
||||||
|
- **services/** — чистые функции, никакого `request`/`Response`
|
||||||
|
- **db/** — чистый CRUD, никакого Flask
|
||||||
|
- **repository.py** — единая точка доступа к данным (DI во все services)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Пошаговый план (8 шагов)
|
||||||
|
|
||||||
|
### Шаг 1: Перенести `db/` как есть
|
||||||
|
|
||||||
|
**Файлы:** `deploy/db/*.py` → `site/db/*.py`
|
||||||
|
|
||||||
|
**Что делать:** Копировать. Менять НИЧЕГО не надо.
|
||||||
|
- `connection.py` уже использует `psycopg2.pool.ThreadedConnectionPool` — идеально для Flask (каждый request — своё соединение из пула)
|
||||||
|
- Все модули зависят только от `connection.query()` и `connection.execute()`
|
||||||
|
|
||||||
|
**Проверка:** `python3 -c "from site.db import documents; print(documents.list_by_batch('test'))"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Шаг 2: Перенести `services/` (бывший `compare/`)
|
||||||
|
|
||||||
|
**Файлы:** `deploy/compare/{parse,classify,grouping,llm,llm_client,metrics}.py` → `site/services/`
|
||||||
|
|
||||||
|
**Что делать:** Копировать, исправить импорты:
|
||||||
|
- `from db import ...` → `from site.db import ...`
|
||||||
|
- `from .parse import ...` → `from site.services.parse import ...`
|
||||||
|
- `from llm_prompt import ...` → `from site.llm_prompt import ...`
|
||||||
|
|
||||||
|
**НЕ переносить:** `upload.py`, `unzip.py`, `process.py` — они привязаны к HTTP и будут переписаны в blueprints.
|
||||||
|
|
||||||
|
**Проверка:** `python3 -c "from site.services.classify import classify_batch; print('ok')"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Шаг 3: Создать `config.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# site/config.py
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_CONFIG = {
|
||||||
|
"host": os.getenv("DB_HOST", "127.0.0.1"),
|
||||||
|
"port": int(os.getenv("DB_PORT", "5432")),
|
||||||
|
"dbname": os.getenv("DB_NAME", "baza"),
|
||||||
|
"user": os.getenv("DB_USER", "super"),
|
||||||
|
"password": os.getenv("DB_PASS", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
|
||||||
|
LLM_KEY = os.getenv("LLM_API_KEY", "")
|
||||||
|
LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b")
|
||||||
|
|
||||||
|
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
|
||||||
|
VERSION = "2.0.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Шаг 4: Blueprint `upload_bp.py` — загрузка файлов
|
||||||
|
|
||||||
|
Самый критичный blueprint. Замена `deploy/compare/upload.py` + `deploy/compare/unzip.py`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# site/routes/upload_bp.py
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from site.services.parse import parse_file
|
||||||
|
from site.db import documents, contracts
|
||||||
|
from site.config import MAX_CONTENT_LENGTH
|
||||||
|
import hashlib, base64, zipfile, io, os
|
||||||
|
|
||||||
|
upload_bp = Blueprint("upload", __name__)
|
||||||
|
|
||||||
|
ALLOWED = {"pdf", "docx", "doc", "zip"}
|
||||||
|
|
||||||
|
@upload_bp.route("/upload", methods=["POST"])
|
||||||
|
def upload():
|
||||||
|
"""Загрузка одного файла → парсинг → БД."""
|
||||||
|
f = request.files.get("files")
|
||||||
|
if not f:
|
||||||
|
return jsonify(ok=False, error="no file"), 400
|
||||||
|
|
||||||
|
ext = f.filename.rsplit(".", 1)[-1].lower() if "." in f.filename else ""
|
||||||
|
if ext not in ALLOWED:
|
||||||
|
return jsonify(ok=False, error=f"unsupported: .{ext}"), 400
|
||||||
|
|
||||||
|
data = f.read()
|
||||||
|
content_hash = hashlib.sha256(data).hexdigest()[:16]
|
||||||
|
|
||||||
|
batch_id = request.form.get("batch_id")
|
||||||
|
zip_source = request.form.get("zip_source")
|
||||||
|
|
||||||
|
# Проверка дубликата по хешу
|
||||||
|
if batch_id:
|
||||||
|
existing = documents.get_by_hash(batch_id, content_hash)
|
||||||
|
if existing:
|
||||||
|
return jsonify(ok=False, error="duplicate", doc_id=existing["id"])
|
||||||
|
|
||||||
|
doc = documents.insert(
|
||||||
|
filename=f.filename,
|
||||||
|
mime_type=f.content_type or "application/octet-stream",
|
||||||
|
original_bytes=base64.b64encode(data).decode(),
|
||||||
|
batch_id=batch_id,
|
||||||
|
zip_source=zip_source,
|
||||||
|
content_hash=content_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Парсинг
|
||||||
|
try:
|
||||||
|
result = parse_file(f.filename, data)
|
||||||
|
if result["status"] == "parsed":
|
||||||
|
documents.set_parsed(doc["id"], result["elements"])
|
||||||
|
else:
|
||||||
|
documents.set_error(doc["id"], result.get("error", "parse failed"))
|
||||||
|
except Exception as e:
|
||||||
|
documents.set_error(doc["id"], str(e))
|
||||||
|
result = {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
contract_id = request.form.get("contract_id")
|
||||||
|
return jsonify(ok=True, doc_id=doc["id"], contract_id=contract_id,
|
||||||
|
parsed={"status": result["status"], "element_count": result.get("element_count", 0)})
|
||||||
|
|
||||||
|
|
||||||
|
@upload_bp.route("/convert-doc", methods=["POST"])
|
||||||
|
def convert_doc():
|
||||||
|
""".doc → .docx через libreoffice."""
|
||||||
|
import subprocess, tempfile
|
||||||
|
f = request.files.get("files")
|
||||||
|
if not f:
|
||||||
|
return jsonify(ok=False, error="no file"), 400
|
||||||
|
data = f.read()
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp:
|
||||||
|
tmp.write(data)
|
||||||
|
doc_path = tmp.name
|
||||||
|
tmpdir = tempfile.mkdtemp()
|
||||||
|
try:
|
||||||
|
subprocess.run(["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path],
|
||||||
|
timeout=30, capture_output=True)
|
||||||
|
docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
|
||||||
|
if docx_files:
|
||||||
|
with open(os.path.join(tmpdir, docx_files[0]), "rb") as out:
|
||||||
|
from flask import send_file
|
||||||
|
return send_file(io.BytesIO(out.read()), mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
||||||
|
return jsonify(ok=False, error="conversion produced no output"), 500
|
||||||
|
finally:
|
||||||
|
os.unlink(doc_path)
|
||||||
|
for x in os.listdir(tmpdir):
|
||||||
|
os.unlink(os.path.join(tmpdir, x))
|
||||||
|
os.rmdir(tmpdir)
|
||||||
|
|
||||||
|
|
||||||
|
@upload_bp.route("/unzip-upload", methods=["POST"])
|
||||||
|
def unzip_upload():
|
||||||
|
"""Распаковать ZIP → список файлов (base64)."""
|
||||||
|
f = request.files.get("files")
|
||||||
|
if not f:
|
||||||
|
return jsonify(ok=False, error="no file"), 400
|
||||||
|
data = f.read()
|
||||||
|
MAX_FILES = 500
|
||||||
|
MAX_UNCOMPRESSED = 500 * 1024 * 1024
|
||||||
|
files = []
|
||||||
|
total = 0
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
if len(zf.namelist()) > MAX_FILES:
|
||||||
|
return jsonify(ok=False, error=f"too many files (max {MAX_FILES})"), 400
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.is_dir():
|
||||||
|
continue
|
||||||
|
name = os.path.basename(info.filename)
|
||||||
|
if not name or ".." in name:
|
||||||
|
continue
|
||||||
|
raw = zf.read(info)
|
||||||
|
total += len(raw)
|
||||||
|
if total > MAX_UNCOMPRESSED:
|
||||||
|
return jsonify(ok=False, error="total uncompressed > 500MB"), 400
|
||||||
|
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
||||||
|
files.append({
|
||||||
|
"filename": name,
|
||||||
|
"ext": ext,
|
||||||
|
"size": len(raw),
|
||||||
|
"data_b64": base64.b64encode(raw).decode(),
|
||||||
|
})
|
||||||
|
return jsonify(ok=True, files=files)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Критично:**
|
||||||
|
- `request.files` вместо `cgi.FieldStorage` — файл уже в памяти
|
||||||
|
- `base64` всё ещё нужен (JS на фронте делает `atob()`)
|
||||||
|
- Таймауты: Flask не режет сам — поставить `timeout` на libreoffice
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Шаг 5: Blueprint `pipeline_bp.py` — SSE + classify
|
||||||
|
|
||||||
|
```python
|
||||||
|
# site/routes/pipeline_bp.py
|
||||||
|
from flask import Blueprint, request, jsonify, Response, stream_with_context
|
||||||
|
from site.services.process import run_pipeline # адаптированный process.py
|
||||||
|
from site.services.classify import classify_batch
|
||||||
|
from site.llm_prompt import build_prompt
|
||||||
|
from site.db import documents
|
||||||
|
import json, re, os, threading, sys
|
||||||
|
|
||||||
|
pipeline_bp = Blueprint("pipeline", __name__)
|
||||||
|
|
||||||
|
@pipeline_bp.route("/process-v2", methods=["GET"])
|
||||||
|
def process_v2():
|
||||||
|
"""SSE-стриминг сравнения."""
|
||||||
|
cid = request.args.get("contract_id")
|
||||||
|
if not cid or not re.fullmatch(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', cid, re.I):
|
||||||
|
return jsonify(ok=False, error="invalid contract_id"), 400
|
||||||
|
|
||||||
|
order_ids = request.args.get("order", "")
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
yield ": ok\n\n"
|
||||||
|
try:
|
||||||
|
for event in run_pipeline(cid, order_ids, build_prompt):
|
||||||
|
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||||
|
except GeneratorExit:
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
stream_with_context(generate()),
|
||||||
|
content_type="text/event-stream; charset=utf-8",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no", # ← nginx не буферизует
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pipeline_bp.route("/api/classify-batch", methods=["POST"])
|
||||||
|
def classify_batch_route():
|
||||||
|
"""Запустить классификацию. Синхронно для малых батчей, 202 для больших."""
|
||||||
|
body = request.get_json()
|
||||||
|
batch_id = body.get("batch_id")
|
||||||
|
if not batch_id:
|
||||||
|
return jsonify(ok=False, error="batch_id required"), 400
|
||||||
|
|
||||||
|
pending = documents.list_pending(batch_id)
|
||||||
|
total = len(pending)
|
||||||
|
if total == 0:
|
||||||
|
return jsonify(ok=False, error="no pending documents"), 400
|
||||||
|
|
||||||
|
# Для ≤10 файлов — синхронно (быстрее, проще)
|
||||||
|
if total <= 10:
|
||||||
|
result = classify_batch(batch_id)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
# Для >10 файлов — в отдельном потоке, сразу 202
|
||||||
|
lock_path = f"/tmp/classify_{batch_id}.lock"
|
||||||
|
if os.path.exists(lock_path):
|
||||||
|
return jsonify(ok=False, error="classify already running"), 409
|
||||||
|
|
||||||
|
with open(lock_path, "w") as lf:
|
||||||
|
lf.write(str(os.getpid()))
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
classify_batch(batch_id)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(lock_path):
|
||||||
|
os.remove(lock_path)
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
|
return jsonify(ok=True, total=total), 202
|
||||||
|
```
|
||||||
|
|
||||||
|
**Критично:**
|
||||||
|
- `compare/process.py` нужно адаптировать: `run_pipeline` должен стать **генератором** (yield события), а не принимать `sse_send` callback
|
||||||
|
- `GeneratorExit` в генераторе — обязательно
|
||||||
|
- `X-Accel-Buffering: no` — иначе nginx буферизует SSE
|
||||||
|
- classify: синхронно для ≤10 файлов, Thread для >10 (вместо subprocess)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Шаг 6: Blueprint `api_bp.py` — всё остальное API
|
||||||
|
|
||||||
|
```python
|
||||||
|
# site/routes/api_bp.py
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from site.db import documents, supplements, contracts, spec_current
|
||||||
|
from site.services.grouping import group_documents, apply_groups
|
||||||
|
from site.db.connection import execute, query
|
||||||
|
|
||||||
|
api_bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
|
@api_bp.route("/api/supplements")
|
||||||
|
def api_supplements():
|
||||||
|
cid = request.args.get("contract_id")
|
||||||
|
if not cid:
|
||||||
|
return jsonify(ok=False, error="contract_id required"), 400
|
||||||
|
rows = supplements.list_by_contract(cid)
|
||||||
|
return jsonify(ok=True, supplements=rows)
|
||||||
|
|
||||||
|
@api_bp.route("/api/documents/<doc_id>")
|
||||||
|
def api_document(doc_id):
|
||||||
|
doc = documents.get(doc_id)
|
||||||
|
if not doc:
|
||||||
|
return jsonify(ok=False, error="not found"), 404
|
||||||
|
return jsonify(ok=True, **{k: doc.get(k) for k in [
|
||||||
|
"id", "filename", "status", "elements_json", "doc_type",
|
||||||
|
"own_number", "parent_number", "doc_date", "counterparty",
|
||||||
|
"classify_status", "classify_raw", "classify_input"
|
||||||
|
]})
|
||||||
|
|
||||||
|
@api_bp.route("/api/documents/<doc_id>", methods=["DELETE"])
|
||||||
|
def api_document_delete(doc_id):
|
||||||
|
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (doc_id,))
|
||||||
|
for s in (supps or []):
|
||||||
|
execute("DELETE FROM spec_current WHERE contract_id = %s AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)", (s["contract_id"], s["id"]))
|
||||||
|
execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
|
||||||
|
execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
|
||||||
|
execute("DELETE FROM documents WHERE id = %s", (doc_id,))
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
@api_bp.route("/api/sync", methods=["POST"])
|
||||||
|
def api_sync():
|
||||||
|
body = request.get_json()
|
||||||
|
keep_ids = set(body.get("keep_ids", []))
|
||||||
|
if len(keep_ids) > 1000:
|
||||||
|
return jsonify(ok=False, error="too many keep_ids"), 400
|
||||||
|
docs = query("SELECT id FROM documents", ())
|
||||||
|
deleted = 0
|
||||||
|
for d in (docs or []):
|
||||||
|
if d["id"] in keep_ids:
|
||||||
|
continue
|
||||||
|
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (d["id"],))
|
||||||
|
for s in (supps or []):
|
||||||
|
execute("DELETE FROM spec_current WHERE contract_id = %s AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)", (s["contract_id"], s["id"]))
|
||||||
|
execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
|
||||||
|
execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
|
||||||
|
execute("DELETE FROM documents WHERE id = %s", (d["id"],))
|
||||||
|
deleted += 1
|
||||||
|
execute("DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)")
|
||||||
|
return jsonify(ok=True, deleted=deleted)
|
||||||
|
|
||||||
|
@api_bp.route("/api/groups")
|
||||||
|
def api_groups():
|
||||||
|
batch_id = request.args.get("batch")
|
||||||
|
if not batch_id:
|
||||||
|
return jsonify(ok=False, error="batch required"), 400
|
||||||
|
result = group_documents(batch_id)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
@api_bp.route("/api/batch-progress")
|
||||||
|
def api_batch_progress():
|
||||||
|
batch_id = request.args.get("batch")
|
||||||
|
if not batch_id:
|
||||||
|
return jsonify(ok=False, error="batch required"), 400
|
||||||
|
counts = documents.count_by_status(batch_id)
|
||||||
|
docs = documents.list_by_batch(batch_id)
|
||||||
|
return jsonify(ok=True, counts=counts, total=len(docs))
|
||||||
|
|
||||||
|
@api_bp.route("/api/apply-groups", methods=["POST"])
|
||||||
|
def api_apply_groups():
|
||||||
|
body = request.get_json()
|
||||||
|
batch_id = body.get("batch_id")
|
||||||
|
groups = body.get("groups", [])
|
||||||
|
if not batch_id:
|
||||||
|
return jsonify(ok=False, error="batch_id required"), 400
|
||||||
|
result = apply_groups(batch_id, groups)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
@api_bp.route("/api/spec-current")
|
||||||
|
def api_spec_current():
|
||||||
|
cid = request.args.get("contract_id")
|
||||||
|
if not cid:
|
||||||
|
return jsonify(ok=False, error="contract_id required"), 400
|
||||||
|
rows = spec_current.list_by_contract(cid)
|
||||||
|
return jsonify(ok=True, rows=rows)
|
||||||
|
|
||||||
|
@api_bp.route("/api/cleanup", methods=["POST"])
|
||||||
|
def api_cleanup():
|
||||||
|
execute("DELETE FROM spec_current")
|
||||||
|
execute("DELETE FROM spec_events")
|
||||||
|
execute("DELETE FROM supplements")
|
||||||
|
execute("DELETE FROM upload_chunks")
|
||||||
|
execute("DELETE FROM documents")
|
||||||
|
execute("DELETE FROM contracts")
|
||||||
|
return jsonify(ok=True, message="all data cleaned")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Шаг 7: `app.py` — точка входа
|
||||||
|
|
||||||
|
```python
|
||||||
|
# site/app.py
|
||||||
|
from flask import Flask, render_template
|
||||||
|
from site.config import VERSION, MAX_CONTENT_LENGTH
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["VERSION"] = VERSION
|
||||||
|
app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
|
||||||
|
|
||||||
|
# Blueprints
|
||||||
|
from site.routes.upload_bp import upload_bp
|
||||||
|
from site.routes.pipeline_bp import pipeline_bp
|
||||||
|
from site.routes.api_bp import api_bp
|
||||||
|
from site.routes.prompts_bp import prompts_bp
|
||||||
|
from site.routes.health_bp import health_bp
|
||||||
|
from site.routes.pages_bp import pages_bp
|
||||||
|
|
||||||
|
app.register_blueprint(upload_bp)
|
||||||
|
app.register_blueprint(pipeline_bp)
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
app.register_blueprint(prompts_bp)
|
||||||
|
app.register_blueprint(health_bp)
|
||||||
|
app.register_blueprint(pages_bp)
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def no_cache(response):
|
||||||
|
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
response.headers["Expires"] = "0"
|
||||||
|
return response
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Шаг 8: Адаптировать `compare/process.py` в генератор
|
||||||
|
|
||||||
|
Текущий `run_pipeline(cid, order_ids, sse_send, build_prompt_fn)` принимает callback `sse_send`. Нужно сделать генератором:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# site/services/process.py
|
||||||
|
def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
||||||
|
"""Generator: yield SSE events."""
|
||||||
|
# ... та же логика, но вместо sse_send(event) → yield event
|
||||||
|
# ... GeneratorExit уже обрабатывается во view
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Что НЕ трогаем (остаётся как есть)
|
||||||
|
|
||||||
|
| Что | Почему |
|
||||||
|
|-----|--------|
|
||||||
|
| `templates/index.html` | Уже работает, меняются только URL (с ВМ на свои) |
|
||||||
|
| `static/*.js` (6 файлов) | Меняется только `VM_API` → `""` (свои же endpoints) |
|
||||||
|
| `convert_doc.py` | Остаётся как утилита (libreoffice), вызывается из upload_bp |
|
||||||
|
| CSS, иконки, модалки | Без изменений |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Изменения во фронтенде (минимальные)
|
||||||
|
|
||||||
|
В `deploy/app.js` (статика) поменять ОДНУ строку:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Было:
|
||||||
|
var VM_API = 'https://contracts.kube5s.ru';
|
||||||
|
// Стало:
|
||||||
|
var VM_API = ''; // все API на том же домене
|
||||||
|
```
|
||||||
|
|
||||||
|
ВСЁ. Больше ничего не меняется — XHR, SSE, fetch работают с теми же путями.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Последовательность выполнения
|
||||||
|
|
||||||
|
| # | Шаг | Сложность | Риск |
|
||||||
|
|---|-----|-----------|------|
|
||||||
|
| 1 | `db/` → `site/db/` | Низкая | Низкий — просто копирование |
|
||||||
|
| 2 | `compare/` → `site/services/` | Низкая | Низкий — правим импорты |
|
||||||
|
| 3 | `config.py` | Низкая | Низкий |
|
||||||
|
| 4 | `upload_bp.py` | Средняя | **Высокий** — ключевой функционал |
|
||||||
|
| 5 | `pipeline_bp.py` + адаптация `process.py` | Высокая | **Высокий** — SSE критичен |
|
||||||
|
| 6 | `api_bp.py` | Средняя | Средний — много ручек |
|
||||||
|
| 7 | `prompts_bp.py`, `health_bp.py`, `pages_bp.py` | Низкая | Низкий |
|
||||||
|
| 8 | `app.py` + тесты | Средняя | Средний |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Чек-лист перед деплоем
|
||||||
|
|
||||||
|
- [ ] `python3 -c "from site.app import app"` — приложение создаётся без ошибок
|
||||||
|
- [ ] `/health` → `{"ok": true}`
|
||||||
|
- [ ] `POST /upload` с реальным PDF → `{"ok": true, "doc_id": "..."}`
|
||||||
|
- [ ] `POST /unzip-upload` с ZIP → список файлов
|
||||||
|
- [ ] `GET /process-v2?contract_id=...` → SSE-поток (curl test)
|
||||||
|
- [ ] `POST /api/classify-batch` → классификация работает
|
||||||
|
- [ ] `GET /api/groups?batch=...` → группы
|
||||||
|
- [ ] `POST /api/apply-groups` → создаются supplements
|
||||||
|
- [ ] `GET /api/spec-current?contract_id=...` → спецификация
|
||||||
|
- [ ] `GET /` → HTML с таблицей файлов
|
||||||
|
- [ ] `no_cache` after_request — есть
|
||||||
|
- [ ] `X-Accel-Buffering: no` на SSE
|
||||||
|
- [ ] `GeneratorExit` в SSE-генераторе
|
||||||
|
- [ ] `stream_with_context` на SSE
|
||||||
|
- [ ] `MAX_CONTENT_LENGTH = 200 MB`
|
||||||
|
- [ ] `VM_API = ''` в app.js (фронтенд)
|
||||||
|
- [ ] Все старые тесты проходят
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Риски и mitigation
|
||||||
|
|
||||||
|
| Риск | Mitigation |
|
||||||
|
|------|-----------|
|
||||||
|
| SSE зависает под нагрузкой | `stream_with_context` + `X-Accel-Buffering: no` |
|
||||||
|
| classify блокирует HTTP | Thread для >10 файлов, sync для ≤10 |
|
||||||
|
| libreoffice падает | timeout=30, отдельный процесс |
|
||||||
|
| Коннекты к БД исчерпываются | ThreadedConnectionPool уже есть, minconn=1, maxconn=10 |
|
||||||
|
| ZIP-бомба | Проверка MAX_UNCOMPRESSED = 500MB, MAX_RATIO |
|
||||||
|
| Загрузка больших PDF (>100MB) | MAX_CONTENT_LENGTH = 200MB, XHR timeout = 180s |
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Рецензия DeepSeek V4 Pro на отчёт Opus
|
||||||
|
|
||||||
|
Дата: 27.06.2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Общая оценка: ⭐⭐⭐⭐⭐ (5/5)
|
||||||
|
|
||||||
|
Opus сделал ТО, ЧТО НУЖНО: прочитал код, нашёл расхождения с документацией, не писал код, не рисовал графы. Это эталонный анализ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что Opus нашёл такого, чего я НЕ заметил
|
||||||
|
|
||||||
|
### 🔴 КРИТИЧЕСКОЕ: PyPDF2 теряет структуру таблиц
|
||||||
|
|
||||||
|
Я читал `parse.py`, видел `PyPDF2`, но **не осознал** что он извлекает текст построчно и не сохраняет колонки. Для спецификаций — это катастрофа. Таблица «наименование | цена | кол-во | сумма | дата» после PyPDF2 станет плоским текстом, и LLM придётся угадывать где какая колонка.
|
||||||
|
|
||||||
|
**Opus прав на 100%.** Это вероятно главный риск точности прямо сейчас.
|
||||||
|
Надо: `pdfplumber` (для цифровых PDF) или `camelot`.
|
||||||
|
|
||||||
|
### 🔴 КРИТИЧЕСКОЕ: Кода сверки CRM↔фискальная НЕТ
|
||||||
|
|
||||||
|
Я знал это из ответов заказчика, но Opus проверил grep'ом (`crm|фискал|fiscal|сверк|PAYG|reconcil`) — нашёл только «акт сверки» как тип мусора. Это greenfield. Меняет планирование: не «эволюция», а «новый модуль».
|
||||||
|
|
||||||
|
### 🟡 ВАЖНОЕ: service-description.md устарел
|
||||||
|
|
||||||
|
Я дал Opus читать `service-description.md` как «актуальный». Opus обнаружил что он описывает парсинг через Lucee (Java POI/PDFBox), а **реальный код** (`upload.py` → `parse.py`) парсит на ВМ через PyPDF2/python-docx. Без Java. Без Lucee.
|
||||||
|
|
||||||
|
**Моя ошибка:** я не перепроверил service-description.md на соответствие коду. Надо поправить доку.
|
||||||
|
|
||||||
|
### 🟡 ВАЖНОЕ: llm_prompt.py зависит от Lucee
|
||||||
|
|
||||||
|
`build_prompt()` (основной пайплайн) ходит HTTP в Lucee за активным промптом. А `build_classify_prompt()` берёт из БД напрямую. Непоследовательно + точка отказа.
|
||||||
|
|
||||||
|
### 🟡 ВАЖНОЕ: chat.cfm содержит захардкоженный API-ключ
|
||||||
|
|
||||||
|
Плюс ссылается на `spec_rows` вместо `spec_current` — сломан вдвойне.
|
||||||
|
|
||||||
|
### 🟢 Безопасность: verify=False в llm.py
|
||||||
|
|
||||||
|
TLS-проверка отключена. Надо включить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Где Opus прав, а где нет
|
||||||
|
|
||||||
|
| Тезис Opus | Моё мнение |
|
||||||
|
|------------|------------|
|
||||||
|
| **Гипотеза 1 (гибрид): ⚠ частично** — «текущий код — чистый pipeline, и это правильно» | ✅ **Согласен.** 131 тест на детерминированные функции — сильный аргумент против агентов. |
|
||||||
|
| **Гипотеза 2 (фильтр мусора): ⚠ частично** — «этап 1 по имени файла ненадёжен» | ⚠ **Частично.** Для имён типа «счет-фактура №123 от 01.01.2025.docx» regex надёжен. Но для «scan001.pdf» — да, бесполезен. Вывод: этап 1 — приоритизация, не жёсткий дроп. |
|
||||||
|
| **Гипотеза 3 (парсинг на ВМ): ❌ отвергнута** | ✅ **Полностью согласен.** Моя гипотеза была основана на устаревшей документации. Парсинг уже на ВМ. |
|
||||||
|
| **Гипотеза 4 (трёхуровневый matching): ⚠ частично** — «difflib опасен на коротких токенах» | ✅ **Согласен.** Примеры «IPv4↔IPv6», «10 кВт↔15 кВт» убедительны. Матчить по структурным атрибутам, не по сырой строке. |
|
||||||
|
| **Гипотеза 5 (MVP): ⚠ частично** — «нужен CRM-прогон уже в v1» | ✅ **Согласен.** Иначе v1 будет «готов», а к цели не приблизимся. |
|
||||||
|
| **Гипотеза 6 (PG-очередь): ✅ подтверждена** | ✅ **Согласен.** Плюс Opus добавил: сейчас очереди нет вообще, in-process ThreadPool. |
|
||||||
|
| **«LLM бесплатный ≠ быстрый»** — не наращивать проходы | ✅ **Согласен.** 5-30s × 100+ файлов = узкое место. |
|
||||||
|
| **«Не отказываться от детерминированного matching»** | ✅ **Согласен.** Воспроизводимость и аудит важнее «бесплатности». |
|
||||||
|
|
||||||
|
### Единственное где я НЕ согласен:
|
||||||
|
|
||||||
|
**Opus говорит «не использовать имя файла для фильтрации».**
|
||||||
|
Я считаю: regex `(сч[её]т|акт|плат[её]ж|УПД|сверк|инвойс|invoice)` по имени файла — ДОСТАТОЧНО надёжен для пред-фильтрации. «Счет-фактура №123.docx» — это всегда мусор. Да, «scan001.pdf» не отфильтруется — но он уйдёт на этап 2 (первые 2KB текста). Риск ложного срабатывания на договоре с именем «счет-фактура» — ноль.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что Opus упустил
|
||||||
|
|
||||||
|
1. **testgen/** — в репо есть `testgen/generate.py`, `testgen/pools.py`, `testgen/templates.py`. Это генератор синтетических тестовых данных. Opus предлагает «тестировать матчинг на синтетике», но не знает что инструмент уже есть.
|
||||||
|
|
||||||
|
2. **convert_server.py роутинг** — Opus не проверил ВСЕ эндпоинты. Например, `/api/batch-progress`, `/api/apply-groups`, `/api/cleanup`, `/api/sync` — они есть, но не проанализированы.
|
||||||
|
|
||||||
|
3. **services/llm.py — расхождение ВМ↔репо** — Opus заметил `verify=False`, но не предложил КАК выяснить какая версия каноническая (репо или ВМ). Я поднимал этот вопрос в `opus-plan-review-2026-06-27.md`.
|
||||||
|
|
||||||
|
4. **LibreOffice — узкое место для .doc** — `/convert-doc` запускает LibreOffice subprocess последовательно с таймаутом 30с. На пачке старых .doc файлов это потенциально медленнее даже LLM. Но на практике .doc — редкость (заказчик говорил про docx/pdf).
|
||||||
|
|
||||||
|
5. **OCR не нужен** — заказчик уточнил: «только текст, сканов не будет». Значит риск «потребуется внешний OCR-сервис» снимается.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Дополнения из второго анализа (подтверждения)
|
||||||
|
|
||||||
|
Второй анализ полностью подтвердил выводы Opus. Дополнительно акцентировано:
|
||||||
|
|
||||||
|
- **Дрейф документации** — не только `service-description.md`, но и другие .md могут устареть. Правило: **код > документация**. Всегда проверять.
|
||||||
|
- **base64 в БД** — `upload.py` кладёт файлы как base64 в `documents`. На 100+ многомегабайтных файлах это раздует БД. Риск масштабирования.
|
||||||
|
- **Нет устойчивой очереди** — `classify.py` = `ThreadPoolExecutor` в одном процессе. Падение процесса = потеря прогресса. PG-очередь добавит durability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итог: что я беру в работу
|
||||||
|
|
||||||
|
### Немедленно (следующий чат, по команде «делай»):
|
||||||
|
|
||||||
|
1. **Заменить PyPDF2 на pdfplumber** в `services/parse.py` — критично для точности
|
||||||
|
2. **Починить chat.cfm** — убрать ключ, `spec_rows` → `spec_current`
|
||||||
|
3. **Включить verify=True** в `services/llm.py`
|
||||||
|
4. **Отвязать llm_prompt.py от Lucee** — `build_prompt` → `db.prompts.get_active()`
|
||||||
|
5. **Поправить service-description.md** — парсинг на ВМ, не через Lucee
|
||||||
|
|
||||||
|
### Вторая очередь:
|
||||||
|
|
||||||
|
6. **Детерминированная проверка `sum == price*qty`** — бесплатный сигнал ошибок
|
||||||
|
7. **Золотой набор** — 30-50 реальных документов с ручной разметкой
|
||||||
|
8. **Метрики** — логировать `_safe_json_parse` срабатывания, UNRESOLVED, латентность
|
||||||
|
|
||||||
|
### Третья очередь (после золотого набора):
|
||||||
|
|
||||||
|
9. **Спроектировать нейтральную CanonicalRow** для CRM↔фискальная
|
||||||
|
10. **Прототип матчинга** на синтетике из testgen/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Вывод
|
||||||
|
|
||||||
|
Opus дал **отличный анализ**. Главная ценность — нашёл расхождения кода и документации, которые я пропустил. PyPDF2 — критическая находка. То что кода CRM-сверки нет — меняет приоритеты: не «эволюция», а «новый модуль».
|
||||||
|
|
||||||
|
**Что отсеялось после двух анализов:**
|
||||||
|
- OCR не нужен (только текст, без сканов)
|
||||||
|
- LLM не использовать агрессивнее (бесплатный ≠ быстрый, 5-30s/вызов)
|
||||||
|
- Агенты не нужны (131 тест на pipeline, детерминизм > гибкость)
|
||||||
|
- RabbitMQ не нужен (PG-очередь достаточна)
|
||||||
|
|
||||||
|
**Что осталось в работе (5 немедленных + 3 второй очереди):**
|
||||||
|
1. pdfplumber вместо PyPDF2
|
||||||
|
2. chat.cfm: ключ + таблица
|
||||||
|
3. llm.py: verify=True
|
||||||
|
4. llm_prompt.py: отвязать от Lucee
|
||||||
|
5. service-description.md: поправить
|
||||||
|
6. Проверка sum==price*qty
|
||||||
|
7. Золотой набор 30-50 документов
|
||||||
|
8. Метрики (_safe_json_parse, UNRESOLVED, латентность)
|
||||||
|
|
||||||
|
Следующий шаг: команда «делай» → правлю пункты 1-5.
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Задание Opus: Архитектурное исследование — Сверка договоров v2
|
||||||
|
|
||||||
|
Дата: 27.06.2026 | От: DeepSeek V4 Pro (через Крупского) | Кому: Opus
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⛔ ЧЕГО НЕ ДЕЛАТЬ
|
||||||
|
|
||||||
|
- **НЕ пиши код.** Ни строчки Python, SQL, JS, ничего. Только концепции.
|
||||||
|
- **НЕ рисуй mermaid-диаграммы.** Только текст. Исполнитель (DeepSeek) сам нарисует если надо.
|
||||||
|
- **НЕ предлагай «переписать с нуля».** Продакшен надо эволюционировать.
|
||||||
|
- **НЕ лезь в файлы за пределами списка ниже.** Экономия токенов.
|
||||||
|
|
||||||
|
## 📋 ЗАЧЕМ ЭТОТ АНАЛИЗ
|
||||||
|
|
||||||
|
Ты — исследователь. Твой отчёт пойдёт **DeepSeek V4 Pro** (мне). Я буду по нему ПИСАТЬ КОД.
|
||||||
|
|
||||||
|
У нас нет промежуточных результатов — неизвестна точность LLM на реальных 100+ документах,
|
||||||
|
неизвестны типичные расхождения CRM↔фискальная. Нужна архитектура, которую можно
|
||||||
|
итеративно улучшать по мере данных.
|
||||||
|
|
||||||
|
Главное — **концептуальные решения**. Не реализации.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⛔ КАКИЕ ФАЙЛЫ СМОТРЕТЬ
|
||||||
|
|
||||||
|
### Код (18 + 18 + 7 = 43 файла):
|
||||||
|
```
|
||||||
|
contractor/index.cfm — HTML/CSS скелет (v1.0.178)
|
||||||
|
contractor/upload.cfm — приём файлов (form/JSON/multipart/iframe)
|
||||||
|
contractor/chunk.cfm — чанковая загрузка
|
||||||
|
contractor/process.cfm — SSE-пайплайн v1 (прямой LLM)
|
||||||
|
contractor/process_v2.cfm — SSE-пайплайн v2 (→ ВМ, event sourcing)
|
||||||
|
contractor/apply_events.cfm — ADD/UPDATE/DELETE → spec_current
|
||||||
|
contractor/extractor.cfm — старый вариант извлечения spec_rows
|
||||||
|
contractor/parser.cfm — парсинг docx/pdf (PDFBox + POI)
|
||||||
|
contractor/differ.cfm — сравнение допников с базовым договором
|
||||||
|
contractor/chat.cfm — Q&A (⚠ сломан: spec_rows вместо spec_current)
|
||||||
|
contractor/api.cfm — создание схемы БД
|
||||||
|
contractor/Application.cfc — конфигурация Lucee + datasource
|
||||||
|
contractor/db.cfc — REST-обёртка БД
|
||||||
|
contractor/view.cfm — просмотр текста документа
|
||||||
|
contractor/prompt.cfm — версионирование промптов
|
||||||
|
contractor/reset_contract.cfm — сброс контракта
|
||||||
|
contractor/test.cfm — тест
|
||||||
|
contractor/jars.cfm — проверка Java-библиотек
|
||||||
|
|
||||||
|
contractor/deploy/convert_server.py — HTTP-роутер (:8766)
|
||||||
|
contractor/deploy/llm_prompt.py — промпты + fallback (⚠ зависит от Lucee)
|
||||||
|
contractor/deploy/classify_worker.py — фоновый процесс (subprocess)
|
||||||
|
contractor/deploy/convert_doc.py — парсинг docx/pdf
|
||||||
|
contractor/deploy/services/upload.py — загрузка
|
||||||
|
contractor/deploy/services/unzip.py — ZIP
|
||||||
|
contractor/deploy/services/classify.py — классификация (LLM: тип/номер/контрагент)
|
||||||
|
contractor/deploy/services/process.py — пайплайн сравнения
|
||||||
|
contractor/deploy/services/llm.py — вызов LLM API (⚠ ВМ↔репо расходятся)
|
||||||
|
contractor/deploy/services/parse.py — PDF (PyPDF2) / DOCX (python-docx)
|
||||||
|
contractor/deploy/services/grouping.py — группировка (гибрид LLM→Python)
|
||||||
|
contractor/deploy/db/connection.py — psycopg2
|
||||||
|
contractor/deploy/db/documents.py — CRUD документов
|
||||||
|
contractor/deploy/db/supplements.py — CRUD дополнений
|
||||||
|
contractor/deploy/db/spec_current.py — CRUD текущей спецификации
|
||||||
|
contractor/deploy/db/spec_events.py — event sourcing (seq, reset)
|
||||||
|
contractor/deploy/db/contracts.py — CRUD контрактов
|
||||||
|
contractor/deploy/db/prompts.py — CRUD промптов (seed defaults)
|
||||||
|
|
||||||
|
contractor/deploy/app.js — фронтенд (VM_API, render, stepper)
|
||||||
|
contractor/deploy/app_utils.js — утилиты
|
||||||
|
contractor/deploy/state.js — центральный state
|
||||||
|
contractor/deploy/files.js — загрузка/рендер файлов
|
||||||
|
contractor/deploy/groups.js — карточки групп
|
||||||
|
contractor/deploy/compare.js — SSE-сравнение
|
||||||
|
contractor/deploy/tests.js — 131 юнит-тест
|
||||||
|
```
|
||||||
|
|
||||||
|
### Документы (6 штук):
|
||||||
|
```
|
||||||
|
History/topics/customer-qa-2026-06-26.md — ответы заказчика (ОБЯЗАТЕЛЬНО)
|
||||||
|
History/architecture/vm-layout.md — ⭐ раскладка ВМ (самый актуальный)
|
||||||
|
History/architecture/service-description.md — описание сервиса
|
||||||
|
History/architecture/two-panel-architecture.md — две панели просмотра
|
||||||
|
History/opus-plan-review-2026-06-27.md — разбор предыдущего плана Opus
|
||||||
|
History/llm-analysis/decoupling-final-plan.md — план размоноличивания JS
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⛔ НЕ ЧИТАТЬ:
|
||||||
|
```
|
||||||
|
History/architecture/architecture.md — ⛔ МЁРТВЫЙ Flask contracts-app/site/
|
||||||
|
History/architecture/block-diagram.md — ⛔ МЁРТВЫЙ Flask
|
||||||
|
History/architecture/pipeline.md — ⛔ МЁРТВЫЙ Flask
|
||||||
|
sim/ testgen/ dogovora/ FILES/ Files/ DOC/ contracts-flask/ contracts-vm/
|
||||||
|
README.md sonnet-v2-request.md
|
||||||
|
History/sessions/ History/features/ History/llm-analysis/*
|
||||||
|
(КРОМЕ decoupling-final-plan.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 КОНТЕКСТ: что уже надумал DeepSeek
|
||||||
|
|
||||||
|
Я (DeepSeek) уже провёл предварительный анализ. Ниже — мои гипотезы.
|
||||||
|
**Твоя задача: подтвердить, опровергнуть или дополнить.** Не просто соглашаться.
|
||||||
|
|
||||||
|
### Гипотеза 1: Гибрид pipeline + агент
|
||||||
|
Pipeline для 95% стандартных случаев (дёшево, предсказуемо).
|
||||||
|
Лёгкий оркестратор-агент — только для краевых случаев (нестандартный формат, ошибка парсинга).
|
||||||
|
Pure agents — дорого: 100 файлов × 500 токенов оркестратора = 50K токенов только на планирование.
|
||||||
|
|
||||||
|
### Гипотеза 2: Трёхэтапный фильтр мусора
|
||||||
|
Этап 1 (0 токенов): regex по имени файла — «счёт», «акт», «платёж» → сразу garbage.
|
||||||
|
Этап 2 (0 токенов): ключевые слова в первых 2KB текста — «СЧЕТ-ФАКТУРА», «АКТ СВЕРКИ» → garbage.
|
||||||
|
Этап 3 (LLM): только оставшиеся → точный тип (contract/supplement/specification).
|
||||||
|
Экономия: ~50% LLM-вызовов при 50% мусора в пачке.
|
||||||
|
|
||||||
|
### Гипотеза 3: Перенос парсинга на ВМ
|
||||||
|
Сейчас: JS → ВМ → Lucee (Java PDFBox/POI) → обратно на ВМ.
|
||||||
|
Предложение: JS → ВМ → pdfplumber + python-docx (без Java, без Lucee).
|
||||||
|
Убирает latency сетевого вызова, упрощает деплой.
|
||||||
|
|
||||||
|
### Гипотеза 4: Трёхуровневый matching CRM↔фискальная
|
||||||
|
Уровень 1 (0 токенов): хеш нормализованного названия.
|
||||||
|
Уровень 2 (0 токенов): fuzzy string matching (difflib).
|
||||||
|
Уровень 3 (LLM): только для 10-20% несопоставленных.
|
||||||
|
80% строк матчатся без LLM.
|
||||||
|
|
||||||
|
### Гипотеза 5: MVP-границы
|
||||||
|
v1: извлечение спецификаций из договоров (без CRM-сверки). Доказать что LLM вообще справляется.
|
||||||
|
v2: сверка CRM↔фискальная (когда качество извлечения подтверждено).
|
||||||
|
v3: красивый UI, чат, автообучение на коррекциях.
|
||||||
|
|
||||||
|
### Гипотеза 6: PostgreSQL-очередь вместо RabbitMQ
|
||||||
|
Для 100+ файлов пару раз в неделю — хватит `documents.classify_status='pending'` + `FOR UPDATE SKIP LOCKED`. RabbitMQ — оверкилл.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ ВОПРОСЫ К OPUS (5 блоков)
|
||||||
|
|
||||||
|
### Блок 1: Насколько агрессивно использовать LLM?
|
||||||
|
|
||||||
|
**Контекст:** LLM заказчика — gpt-oss-120b через api.aillm.ru — **БЕСПЛАТНЫЙ**. Никаких затрат на токены.
|
||||||
|
|
||||||
|
Сейчас LLM используется 3 раза за прогон:
|
||||||
|
- Классификация (тип/номер/контрагент)
|
||||||
|
- Извлечение спецификации
|
||||||
|
- Сравнение ДС
|
||||||
|
|
||||||
|
**Вопросы:**
|
||||||
|
1.1. Имеет ли смысл использовать LLM **больше** раз на одном документе? Например:
|
||||||
|
- Два прохода извлечения (второй — верификация первого)?
|
||||||
|
- LLM-as-judge: проверять свой же результат и исправлять ошибки?
|
||||||
|
- LLM для принятия решений по ходу пайплайна (оркестратор)?
|
||||||
|
|
||||||
|
1.2. Где LLM **реально** добавляет ценность, а где детерминированный код справится лучше?
|
||||||
|
(С учётом что LLM медленный: 2-30 секунд на вызов.)
|
||||||
|
|
||||||
|
1.3. Если LLM бесплатный — может **вообще отказаться от детерминированного matching** (Гипотеза 4) и всё гонять через LLM? Плюсы/минусы.
|
||||||
|
|
||||||
|
### Блок 2: Архитектура — pipeline или агент?
|
||||||
|
|
||||||
|
2.1. Критика гибридного подхода (Гипотеза 1). Что я упустил? В каких сценариях чистый pipeline или чистые агенты были бы лучше?
|
||||||
|
|
||||||
|
2.2. Если гибрид — где конкретно проходят границы? Какие решения принимает оркестратор, какие — pipeline?
|
||||||
|
|
||||||
|
2.3. Есть ли смысл в нескольких специализированных агентах (parse-agent, classify-agent, compare-agent) вместо одного оркестратора? Плюсы/минусы.
|
||||||
|
|
||||||
|
### Блок 3: Обработка 100+ файлов и фильтрация
|
||||||
|
|
||||||
|
3.1. Критика трёхэтапного фильтра (Гипотеза 2). Какие документы он пропустит? Ложные срабатывания?
|
||||||
|
|
||||||
|
3.2. Парсинг на ВМ (Гипотеза 3) — правильно ли отказываться от Java PDFBox/POI в пользу pdfplumber/python-docx? Качество парсинга таблиц упадёт или вырастет?
|
||||||
|
|
||||||
|
3.3. Какие ещё узкие места будут при 100+ файлах, кроме классификации? Память? Сеть? Диск?
|
||||||
|
|
||||||
|
### Блок 4: Сверка CRM ↔ фискальная
|
||||||
|
|
||||||
|
4.1. Критика трёхуровневого matching (Гипотеза 4). В каких случаях fuzzy matching (difflib) даст ложные срабатывания? Примеры.
|
||||||
|
|
||||||
|
4.2. PAYG (суффикс `-m`, нет артикулов): как сопоставлять? Достаточно убирать `-m` и сравнивать названия, или нужна более сложная логика?
|
||||||
|
|
||||||
|
4.3. Если заказчик НЕ даст формат CRM в ближайшее время — как спроектировать модуль чтобы не простаивать? Что можно сделать уже сейчас (без CRM-данных)?
|
||||||
|
|
||||||
|
### Блок 5: Итеративность и метрики
|
||||||
|
|
||||||
|
5.1. Критика MVP-границ (Гипотеза 5). Может что-то из v2 нужно уже в v1? Или наоборот — что-то из v1 отложить?
|
||||||
|
|
||||||
|
5.2. Какие **конкретные метрики** внедрить с первого дня? Не «точность» вообще, а что именно измерять? Как измерять без эталонных данных?
|
||||||
|
|
||||||
|
5.3. Цикл обратной связи: заказчик проверяет → исправляет → система учитывает. Как это делать без дообучения модели (gpt-oss-120b — API, не наша)? Только few-shot в промпте? Или есть другие подходы?
|
||||||
|
|
||||||
|
5.4. **Главный вопрос:** мы не знаем точность LLM. С чего начать? Просто прогнать 100 реальных документов через текущий код и посмотреть? Или сначала улучшить фильтрацию/промпты, а потом прогонять?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 ФОРМАТ ОТВЕТА
|
||||||
|
|
||||||
|
1. **Executive Summary** — 3-5 абзацев, главные выводы
|
||||||
|
2. **По каждой гипотезе** — вердикт (✅ подтверждаю / ⚠ частично / ❌ отвергаю) + аргументация
|
||||||
|
3. **Ответы на вопросы** — по блокам, кратко
|
||||||
|
4. **Что я упустил** — твои собственные идеи, которых нет в моих гипотезах
|
||||||
|
5. **MVP-план** — что делать в ближайшие 2 недели (пункты, не код)
|
||||||
|
6. **Риски** — что может пойти не так
|
||||||
|
|
||||||
|
## ⚠️ ОГРАНИЧЕНИЯ
|
||||||
|
|
||||||
|
- Конфиденциальность: никаких S3, только локал / файловая шара
|
||||||
|
- Managed Lucee: не можем ставить pip-пакеты на нём
|
||||||
|
- ВМ: 5.172.178.213, Ubuntu, Python 3.12, PostgreSQL 15
|
||||||
|
- LLM: gpt-oss-120b, 8000 токенов, ~5-30s на вызов, БЕСПЛАТНО
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Ответ Опуса — фоновая классификация при 50+ файлах
|
||||||
|
|
||||||
|
Ответ на `History/opus-classify-async-question.md` от 25.06.2026.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Диагноз: что рвётся на самом деле
|
||||||
|
|
||||||
|
**nginx НЕ виноват.** `classify-batch` отдаёт `202` мгновенно. `batch-progress` — короткие независимые запросы, укладываются в 30с. Классификация идёт `classify_worker → api.aillm.ru` напрямую, минуя nginx.
|
||||||
|
|
||||||
|
Реальное узкое место — **симулятор сдаётся на 4-й минуте** (120 итераций × 2с), а 69 файлов при 4 воркерах не успевают: 69 / 4 ≈ 18 волн × 10-15с/LLM-вызов = 3-5 минут > лимита симулятора.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ответы
|
||||||
|
|
||||||
|
### Q1. `subprocess.Popen` для прода — норм?
|
||||||
|
|
||||||
|
Для масштаба сотни-тысячи файлов регулярно `Popen`-на-запрос **неадекватен**:
|
||||||
|
- Нет супервизии: упал worker — никто не узнал (stdout/stderr в DEVNULL)
|
||||||
|
- Нет авто-рестарта
|
||||||
|
- Гонки при параллельных батчах с одним `batch_id`
|
||||||
|
|
||||||
|
**Целевая архитектура:** постоянный worker-сервис под отдельным systemd-юнитом + очередь задач на основе БД.
|
||||||
|
|
||||||
|
Почему БД-очередь, а не Redis:
|
||||||
|
- Work items уже в БД (`documents.classify_status='pending'`)
|
||||||
|
- Устойчиво к рестартам ВМ — resumable
|
||||||
|
- Redis не установлен в проде
|
||||||
|
- Для одной ВМ Celery/RQ — оверкилл
|
||||||
|
|
||||||
|
Дополнительно при тысячах файлов:
|
||||||
|
- retry/backoff на 429/5xx от api.aillm.ru
|
||||||
|
- rate-limit к LLM API
|
||||||
|
- Аккуратное повышение параллелизма (не 4 воркера, а 10-15)
|
||||||
|
|
||||||
|
### Q2. nginx при долгих запросах
|
||||||
|
|
||||||
|
nginx НЕ узкое место. classify-batch=202, batch-progress<30s, классификация идёт worker→LLM напрямую. Реальное узкое: throughput (4 воркера) + лимит симулятора. Прогресс durable в БД, UI не зависит от дедлайна.
|
||||||
|
|
||||||
|
### Q3. Два classify подряд — два Popen
|
||||||
|
|
||||||
|
- **Разные `batch_id`** — безопасно. Каждый процесс работает только над своими документами.
|
||||||
|
- **Один и тот же `batch_id` дважды** — гонка! Оба сделают `reset_classify_status` + повторные LLM-вызовы → двойные траты токенов, неконсистентные счётчики.
|
||||||
|
|
||||||
|
**Решение:** НЕ убивать предыдущий процесс (опасно — может испортить БД). Guard через проверку: если для `batch_id` уже есть running-процесс → вернуть `{"ok": false, "error": "already running"}`. Реализация: либо файл-лок (`/tmp/classify_<batch_id>.lock`), либо запись в БД.
|
||||||
|
|
||||||
|
### Q4. Мониторинг/логирование
|
||||||
|
|
||||||
|
DEVNULL недопустим на масштабе — падения невидимы.
|
||||||
|
|
||||||
|
**Минимум сейчас:** лог-файл per batch `/home/naeel/contracts/logs/classify_<batch_id>.log` (Python logging) вместо DEVNULL.
|
||||||
|
|
||||||
|
**Целевое:** job-таблица в БД:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE classify_jobs (
|
||||||
|
batch_id UUID PRIMARY KEY,
|
||||||
|
started_at TIMESTAMP,
|
||||||
|
finished_at TIMESTAMP,
|
||||||
|
total INT, done INT, failed INT,
|
||||||
|
error_text TEXT,
|
||||||
|
pid INT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
Даёт: честный прогресс, видимость падений, per-doc retry, аудит.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Целевая архитектура (будущее)
|
||||||
|
|
||||||
|
```
|
||||||
|
systemd: contracts.service (HTTP)
|
||||||
|
systemd: classify-worker.service (фоновая классификация)
|
||||||
|
|
||||||
|
flow:
|
||||||
|
HTTP → 202 + запись в classify_jobs (status='queued')
|
||||||
|
classify-worker (постоянно):
|
||||||
|
SELECT batch_id FROM classify_jobs WHERE status='queued' LIMIT 1
|
||||||
|
→ status='running'
|
||||||
|
→ classify_batch(batch_id)
|
||||||
|
→ status='done' + метрики
|
||||||
|
→ следующий батч
|
||||||
|
|
||||||
|
Прогресс: documents.classify_status (pending/classified/failed)
|
||||||
|
Поллинг: GET /api/batch-progress?batch=X → count by status
|
||||||
|
```
|
||||||
|
|
||||||
|
Преимущества:
|
||||||
|
- Устойчиво к рестартам (состояние в БД)
|
||||||
|
- Один процесс обрабатывает батчи последовательно — нет гонок
|
||||||
|
- systemd мониторит и рестартует при падении
|
||||||
|
- Логи systemd/journald
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Минимум сейчас (без переписывания архитектуры)
|
||||||
|
|
||||||
|
1. **Лог-файл вместо DEVNULL:** `stdout=open(log_path, 'w')`
|
||||||
|
2. **Лимит симулятора:** увеличить `MAX_WAIT` до 600с (10 мин) для bulk-тестов
|
||||||
|
3. **Guard от двойного classify:** файл-лок `/tmp/classify_<batch_id>.lock` — если существует, вернуть "already running"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Вопрос к Опусу — фоновая классификация при 50+ файлах
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
`POST /api/classify-batch` получает `{batch_id}`, запускает `classify_worker.py`
|
||||||
|
через `subprocess.Popen` и сразу возвращает `202 {total: N}`.
|
||||||
|
Фронтенд поллит `/api/batch-progress` каждые 2с пока `done >= total`.
|
||||||
|
|
||||||
|
`classify_worker.py` — отдельный питон-процесс, импортирует `services/classify.py`,
|
||||||
|
у которого внутри `ThreadPoolExecutor(max_workers=4)` для параллельных
|
||||||
|
LLM-вызовов (один файл = один LLM-запрос к api.aillm.ru).
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
С 4 файлами полный цикл работает. С 69 файлами:
|
||||||
|
- HTTP-сервер жив, `/health` отвечает
|
||||||
|
- `classify_worker` работает
|
||||||
|
- Симулятор с внешней машины не дожидается конца — на 5+ минутах рвётся сеть/nginx
|
||||||
|
|
||||||
|
## Вопросы
|
||||||
|
|
||||||
|
1. Архитектурно `subprocess.Popen` норм для прода? Или что-то более надёжное (очередь, systemd-таймер, воркер-пул)?
|
||||||
|
|
||||||
|
2. Что делать с nginx при долгих запросах? `batch-progress` — короткий поллинг, он не должен рваться. Но сам classify через фронтенд не идёт — только через воркер. Где узкое место?
|
||||||
|
|
||||||
|
3. При двух быстрых классификациях подряд — второй `Popen` создаст второй процесс. Старый ещё не умер. Надо проверять и убивать предыдущий? Или пусть оба работают (разные batch_id)?
|
||||||
|
|
||||||
|
4. Как правильно мониторить/логировать фоновый процесс? Сейчас stdout/stderr в `/dev/null`.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# DrHider — ревью Opus'а и исправления (2026-06-29)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
DrHider — сервис обфускации документов. Изначально впихнут в convert_server.py (общий сервер сверки договоров). Сервер падал из-за отсутствия БД → 502. Вынесен в standalone drhider_server.py на порту 8767.
|
||||||
|
|
||||||
|
## Найденные баги (Opus)
|
||||||
|
|
||||||
|
### CRITICAL
|
||||||
|
| # | Что | Где | Исправлено |
|
||||||
|
|---|-----|-----|-----------|
|
||||||
|
| C1 | Path traversal: `/static/../etc/passwd` читает любой файл на ВМ | `convert_server.py:_handle_app_js` | ✅ `os.path.realpath` + проверка `startswith(base)` + whitelist `.js`/`.svg` |
|
||||||
|
|
||||||
|
### HIGH
|
||||||
|
| # | Что | Где | Исправлено |
|
||||||
|
|---|-----|-----|-----------|
|
||||||
|
| H1 | Нет лимита размера тела → memory DoS → OOM-kill | `drhider_server.py:_handle_drhider` | ✅ `MAX_BODY = 200MB`, 413 при превышении |
|
||||||
|
| H2 | DrHider дублирован в convert_server.py (БД-зависимом) | `convert_server.py` | ✅ Удалён роут `/api/drhider` и метод `_handle_drhider` |
|
||||||
|
|
||||||
|
### MEDIUM
|
||||||
|
| # | Что | Где | Исправлено |
|
||||||
|
|---|-----|-----|-----------|
|
||||||
|
| M1 | `generate_passport`: `re.match().group(0)` → AttributeError если LLM вернёт не «паспорт» | `drhider.py` | ✅ `m.group(0) if m else ""` |
|
||||||
|
| M2 | Замена по подстроке без границ слова | `drhider.py` | ⬜ отложено |
|
||||||
|
| M3 | Три разных версии: 1.0, 1.2, v1.3 | все файлы | ✅ везде 1.3 |
|
||||||
|
|
||||||
|
### LOW
|
||||||
|
| # | Что | Где | Исправлено |
|
||||||
|
|---|-----|-----|-----------|
|
||||||
|
| L1 | Мусорные импорты: `io as io_mod`, `hashlib`, дубль `import cgi` | все файлы | ✅ удалены |
|
||||||
|
| L2 | `sorted_keys` пересчитывается на каждый абзац docx | `drhider.py` | ⬜ отложено |
|
||||||
|
| L3 | CORS `*` на эндпоинте обфускации | оба сервера | ⬜ отложено |
|
||||||
|
| L4 | Утечка временных файлов в `_handle_convert_doc` | `convert_server.py` | ⬜ отложено |
|
||||||
|
|
||||||
|
## Версии после исправлений
|
||||||
|
- `DRHIDER_VERSION = "1.3"` (drhider.py)
|
||||||
|
- `DRHIDER_SERVER_VERSION = "1.3"` (drhider_server.py)
|
||||||
|
- `v1.3` (drhider.html)
|
||||||
|
|
||||||
|
## Архитектура (после исправлений)
|
||||||
|
- `:8766` — convert_server.py (сверка договоров, с БД или без)
|
||||||
|
- `:8767` — drhider_server.py (только обфускация, БД не нужна)
|
||||||
|
- nginx: `/api/drhider` → `:8767`, остальные `/api/*` → `:8766`
|
||||||
|
- systemd: `contracts.service` + `contracts-drhider.service`
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Запрос к Opus — генератор тестовых документов + симулятор
|
||||||
|
|
||||||
|
## Что за сервис
|
||||||
|
|
||||||
|
Сервис для сверки договоров колокейшн/ЦОД через LLM.
|
||||||
|
Pipeline: загрузка → парсинг → классификация → группировка → сравнение.
|
||||||
|
Одна HTML-страница, бэкенд Python/PostgreSQL.
|
||||||
|
|
||||||
|
## Реальные файлы (проанализированы)
|
||||||
|
|
||||||
|
### Договор: `договор-XXX001-03700.docx`
|
||||||
|
|
||||||
|
Заголовок: "Договор на оказание технологических услуг"
|
||||||
|
Номер: № 03700_1
|
||||||
|
Стороны: ЗАО "XXX001" + ООО "НУБЕС", г. Москва, 01.02.2026
|
||||||
|
Секции: Термины, Предмет, Права/Обязанности, Стоимость, Оплата, ...
|
||||||
|
Таблица реквизитов (Заказчик | Исполнитель)
|
||||||
|
|
||||||
|
### Допсоглашение: `допник-1-XXX003-01300_2.docx`
|
||||||
|
|
||||||
|
Заголовок: "Дополнительное Соглашение №1"
|
||||||
|
Привязка: "к Договору № 01300_2 от 20.12.2025"
|
||||||
|
Стороны: АО "XXX003" + ООО "НУБЕС"
|
||||||
|
|
||||||
|
Таблица инсталляционных услуг (6 колонок):
|
||||||
|
```
|
||||||
|
№ | Наименование услуг | Цена | Объем | Сумма | Дата начала
|
||||||
|
1 | Организация L2 канала без резерва, 1 Гбит/с | 8 283,80 | 1 | 8 283,80 | 26.03.2026
|
||||||
|
| Итого инсталляционный платеж | | | 8 283,80 | -
|
||||||
|
```
|
||||||
|
|
||||||
|
Таблица абонентских услуг (7 колонок, +дата окончания):
|
||||||
|
```
|
||||||
|
№ | Наименование услуг | Цена | Объем | Сумма | Дата начала | Дата оконч.
|
||||||
|
1 | Аренда порта без резерва, 1 Гбит/с | 1 525,00 | 1 | 1 525,00 | 26.03.2026 |
|
||||||
|
2 | Аренда публичной подсети /30 | 589,66 | 1 | 589,66 | 26.03.2026 |
|
||||||
|
3 | Интернет без защиты от DDoS, 100 Мбит | 7 015,00 | 1 | 7 015,00 | 26.03.2026 |
|
||||||
|
4 | Next Generation Cloud (AMD 4.0 ГГц) | 123 823,00| 1 | 123 823,00 | 26.03.2026 |
|
||||||
|
5 | Next Generation Cloud (AMD 4.0 ГГц) | 4 928,30 | 1 | 4 928,30 | 29.01.2026 |
|
||||||
|
| Итого абонентский платеж | | | 137 880,96 | - |
|
||||||
|
```
|
||||||
|
|
||||||
|
### Спецификация: `спецификация-XXX001-03700.docx`
|
||||||
|
|
||||||
|
Заголовок: "Приложение № 1 к Договору № 03700_1 от 01.02.2026"
|
||||||
|
Секция: "Абонентские услуги с 01.04.2026 – 24.04.2026"
|
||||||
|
Общая стоимость: 769 279,53 руб.
|
||||||
|
|
||||||
|
Таблица (6 колонок, 9 строк):
|
||||||
|
```
|
||||||
|
№ | Наименование услуг | Цена | Объем | Сумма | Дата начала
|
||||||
|
1 | Аренда стойко-места | 213 905,44 | 3 | 641 716,32 | 01.04.2026
|
||||||
|
2 | Аренда PDU: 3Ф 32А вертикальный | 2 511,17 | 3 | 7 533,51 | 01.04.2026
|
||||||
|
3 | Организация L2 канала без резерва, 1 Гбит | 11 016,36 | 2 | 22 032,72 | 01.04.2026
|
||||||
|
4 | Next Generation Cloud (Intel 3.0 ГГц) | 76 704,00 | 1 | 76 704,00 | 01.04.2026
|
||||||
|
5 | Аренда IPv4 адреса /32 | 157,28 | 5 | 786,40 | 01.04.2026
|
||||||
|
6 | Аренда IPv4 подсети /30 | 629,10 | 2 | 1 258,20 | 01.04.2026
|
||||||
|
7 | Организация cross-connect: ВОЛС, 2×SMF | 4 192,40 | 3 | 12 577,20 | 06.04.2026
|
||||||
|
8 | Организация cross-connect: UTP | 840,94 | 1 | 840,94 | 06.04.2026
|
||||||
|
9 | Next Generation Cloud (Intel 3.0 ГГц) | 5 830,24 | 1 | 5 830,24 | 06.04.2026
|
||||||
|
```
|
||||||
|
|
||||||
|
### Имена файлов (паттерн)
|
||||||
|
|
||||||
|
```
|
||||||
|
договор-{КОМПАНИЯ}-{НОМЕР}.docx КОМПАНИЯ=XXX001..XXX010, НОМЕР=03700
|
||||||
|
допник-{N}-{КОМПАНИЯ}-{НОМЕР}.docx N=1,2,3...
|
||||||
|
спецификация-{КОМПАНИЯ}-{НОМЕР}.docx привязана к договору по НОМЕРу
|
||||||
|
```
|
||||||
|
|
||||||
|
Иерархия: спецификация/допник ссылаются на договор через НОМЕР и дату.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что нужно от Опуса
|
||||||
|
|
||||||
|
### 1. СХЕМА генерации ~100 файлов (не код!)
|
||||||
|
|
||||||
|
- Сколько контрактов (3-5?), сколько файлов каждого типа
|
||||||
|
- Вариативность: компании XXX001-XXX010, номера, даты ±месяцы, цены ±20%
|
||||||
|
- Типы услуг из пула: Аренда стойко-места, PDU, L2 канал, IPv4, cross-connect, Cloud, Интернет
|
||||||
|
- **Ошибочные:** без номера, битый XML, пустой, только таблицы, .doc формат, дубликаты имён
|
||||||
|
- Как генерировать python-docx: структура шаблонов
|
||||||
|
|
||||||
|
### 2. СТРАТЕГИЯ симулятора (не код!)
|
||||||
|
|
||||||
|
- Сценарии: хэппи-путь, зигзаги (удалил→добавил), тупые действия (дубликаты OK/Отмена, всё удалить)
|
||||||
|
- Тайминги между действиями
|
||||||
|
- ZIP с дубликатами
|
||||||
|
- Верификация после каждого шага: число файлов, статусы, группы
|
||||||
|
|
||||||
|
Код напишу я сам.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Схема Опуса — генератор + симулятор (v1.0)
|
||||||
|
|
||||||
|
Ответ Опуса от 25.06.2026 на запрос `History/opus-generator-request.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Оценка схемы
|
||||||
|
|
||||||
|
### Сильные стороны
|
||||||
|
|
||||||
|
1. **v1→v2 diff-пары** — ключевая идея. Генератор закладывает конкретные изменения между версиями спецификаций (цена, объём, ADD/DELETE/UPDATE), и верификатор проверяет что compare нашёл именно их. Это даёт детерминированную проверку самой важной функции сервиса.
|
||||||
|
|
||||||
|
2. **manifest.json как ground truth** — каждая проверка сверяется с эталоном, а не с «примерно ожидаемым». Никакой неоднозначности.
|
||||||
|
|
||||||
|
3. **Пул услуг из реальных данных** — стойко-место, PDU, L2-канал, cross-connect, Cloud, IPv4. Не выдуманные, а те что в реальных файлах.
|
||||||
|
|
||||||
|
4. **17 ошибочных кейсов** — покрытие крайне широкое: от битого XML до сиротских спецификаций.
|
||||||
|
|
||||||
|
5. **Структура папок** — `testgen/` + `sim/` с чётким разделением генерации и симуляции.
|
||||||
|
|
||||||
|
### Что нужно уточнить/доработать
|
||||||
|
|
||||||
|
1. **Симулятор: API или браузер?** Опус описывает «реальное приложение (API/UI)». Два варианта:
|
||||||
|
- **curl-симулятор** (только API): дёргаем `/upload`, `/api/classify-batch`, `/api/groups`, `/process-v2`. Быстро, дёшево, но не проверяет фронтенд (duplicate confirm, renderFiles, SSE в UI).
|
||||||
|
- **Playwright** (полный UI): открывает браузер, кликает кнопки, читает DOM. Медленно, но проверяет ВСЁ включая confirm-диалоги.
|
||||||
|
- **Рекомендация**: curl для генерации/классификации/групп + выборочно Playwright для UI-специфичных сценариев (дубликаты, confirm, удаление).
|
||||||
|
|
||||||
|
2. **.doc файлы** — парсинг `допник-1-XXX002-01200_3.doc` не удался (старый формат). Нужен LibreOffice для конвертации, либо исключить .doc из генерации и использовать только .docx.
|
||||||
|
|
||||||
|
3. **Стресс-сценарий F** — rapid-fire загрузки во время classify. Это может быть сложно воспроизвести и проверить. Вероятно, отложить на потом.
|
||||||
|
|
||||||
|
4. **Количество файлов** — раскладка даёт ~92 файла + edge cases. Можно докрутить до ровно 100 добавив ещё дубликатов или мусора.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Исходная схема Опуса
|
||||||
|
|
||||||
|
### Часть 1 — Генерация ~100 файлов
|
||||||
|
|
||||||
|
**Базовая структура:** 10 компаний × 10 договоров × (спецификация v1 + v2 + 1-3 допника)
|
||||||
|
|
||||||
|
**Раскладка:**
|
||||||
|
| Категория | Кол-во | Зачем |
|
||||||
|
|---|---|---|
|
||||||
|
| Договоры (валидные) | 10 | по 1 на компанию |
|
||||||
|
| Спецификации v1 | 10 | базовая версия |
|
||||||
|
| Спецификации v2 (ревизия) | 10 | diff для сравнения |
|
||||||
|
| Допники | 18 | 1–3 на договор |
|
||||||
|
| Битые/edge .docx | 15 | ошибки парсинга/классификации |
|
||||||
|
| Форматы .doc/.pdf | 12 | ветка конвертера |
|
||||||
|
| ZIP-архивы | 5 | внутри — наборы файлов |
|
||||||
|
| Дубликаты (имя/контент) | 10 | upload-логика |
|
||||||
|
| Мусор (не договор) | 10 | негативная классификация |
|
||||||
|
| **Итого** | **~100** | |
|
||||||
|
|
||||||
|
**Оси вариативности:**
|
||||||
|
- Компания: ЗАО/ООО/АО/ПАО/ИП, латиница/кириллица
|
||||||
|
- Номер: 5 цифр, опц. суффикс `_N`, edge — буквы/длинный/пустой
|
||||||
|
- Дата: 12.2025–06.2026, форматы `01.02.2026` и «01 февраля 2026»
|
||||||
|
- Цены: базовые ±20%, разрядность с пробелом
|
||||||
|
- Услуги (пул): стойко-место, PDU, L2-канал, IPv4 /30 и /32, cross-connect ВОЛС/UTP, Cloud Intel/AMD, Интернет, порт, подсеть
|
||||||
|
- Таблица: 3–12 строк, 6 или 7 колонок
|
||||||
|
|
||||||
|
**Различия v1→v2 (для компаратора):** изменение цены, изменение объёма, добавленная услуга, удалённая услуга, переименование, сдвиг даты, пересчёт итога.
|
||||||
|
|
||||||
|
**Ошибочные кейсы (17 шт):** нет номера, нестандартный заголовок, битый XML, пустой файл, только таблицы, только текст, .doc формат, битый PDF, дубликат имени, дубликат контента, гигант (500 строк), не-договор, сирота (спека без договора), рассинхрон (имя файла ≠ номер в тексте).
|
||||||
|
|
||||||
|
**Parent-child:** ключ — НОМЕР. Генератор связывает договор→спеку/допник по НОМЕРу.
|
||||||
|
|
||||||
|
**Шаблоны python-docx:**
|
||||||
|
- Билдеры: `build_contract(ctx)`, `build_spec(ctx)`, `build_addendum(ctx)`
|
||||||
|
- ctx = {company, number, date, parties, services[], totals}
|
||||||
|
- Хелперы: `add_title`, `add_section`, `add_services_table`
|
||||||
|
- Битый XML: валидный docx → распаковать zip → испортить document.xml → запаковать
|
||||||
|
- .doc: docx → libreoffice --convert-to doc
|
||||||
|
- Битый PDF: docx → pdf → обрезать байты
|
||||||
|
|
||||||
|
### Часть 2 — Симулятор
|
||||||
|
|
||||||
|
**Словарь действий:** upload, delete, classify, groups, compare (SSE), confirm/cancel, reset.
|
||||||
|
|
||||||
|
**Сценарии:**
|
||||||
|
- **A. Хэппи-путь:** договор + спека v1 + v2 → classify → группа → compare → diff совпал
|
||||||
|
- **B. Зигзаг:** загрузил 3 → удалил 1 → добавил 2 → classify → проверить консистентность
|
||||||
|
- **C. Bulk:** все ~100 разом → classify → число групп, битые ✗
|
||||||
|
- **D. Тупые действия:** дубликат (OK/Отмена), удалить всё, смешать форматы, classify на нуле, compare с одной версией
|
||||||
|
- **E. ZIP:** валидный, с дубликатами, с битым файлом внутри
|
||||||
|
- **F. Стресс:** случайные паузы, rapid-fire, загрузка во время classify
|
||||||
|
|
||||||
|
**Тайминги:** think-time (random 0.1-3с), classify — поллинг, compare — SSE до done/таймаут.
|
||||||
|
|
||||||
|
### Часть 3 — Верификация
|
||||||
|
|
||||||
|
После каждого шага:
|
||||||
|
- `state.files` count == ожидаемого
|
||||||
|
- Статусы ✓/✗ соответствуют (битые → ✗)
|
||||||
|
- После classify: группы есть, состав по НОМЕРу верный
|
||||||
|
- Сироты без краша
|
||||||
|
- Compare: diff == заложенному (ground truth из manifest.json)
|
||||||
|
- Дубликаты: счётчик после OK ≠ после Отмена
|
||||||
|
- Нет 500-х, нет необработанных исключений
|
||||||
|
- Идемпотентность classify
|
||||||
|
|
||||||
|
**Ground truth:** `manifest.json` — что сгенерировано + ожидаемые группы и diff-ы.
|
||||||
|
|
||||||
|
### Структура папок
|
||||||
|
|
||||||
|
```
|
||||||
|
testgen/
|
||||||
|
pools.py # услуги, компании, цены
|
||||||
|
templates.py # build_contract / build_spec / build_addendum
|
||||||
|
corrupt.py # порча docx/pdf, конвертация .doc
|
||||||
|
generate.py # оркестратор → out/ + manifest.json
|
||||||
|
out/{valid,errors,formats,zips,duplicates}/
|
||||||
|
manifest.json # ground truth
|
||||||
|
|
||||||
|
sim/
|
||||||
|
actions.py # обёртки над API
|
||||||
|
scenarios.py # A..F
|
||||||
|
verify.py # чек-лист
|
||||||
|
run.py
|
||||||
|
```
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Разбор плана Opus от 2026-06-27
|
||||||
|
|
||||||
|
## Что произошло
|
||||||
|
|
||||||
|
Opus'у дали задачу: «изучи код и напиши подробный план для DeepSeek V4 Pro по созданию параллельного Flask-стека 1:1 с Lucee↔ВМ, домен check.kube5s.ru».
|
||||||
|
|
||||||
|
Opus изучил репозиторий (без доступа к ВМ), считая `contractor/deploy/` зеркалом продакшена, и выдал план из 6 фаз.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что Opus выяснил правильно
|
||||||
|
|
||||||
|
### Архитектура продакшена
|
||||||
|
- **Lucee — почти пустая морда.** Только `index.cfm` (HTML+CSS-скелет), `chat.cfm` (Q&A), `Application.cfc`. Вся логика — на ВМ.
|
||||||
|
- **JS-файлы** (app.js, files.js, groups.js, state.js, compare.js, app_utils.js) — клиентские, загружаются через `<script src>` из `/static/` на ВМ.
|
||||||
|
- **Бэкенд на ВМ** — `convert_server.py` на порту 8766, импортирует `services/*.py` и `db/*.py`.
|
||||||
|
- **Nginx** маршрутизирует: `/` → Flask :5001 (UI), `/upload`, `/convert-doc`, `/process-v2` и т.д. → Python :8766 (API), `/lucee/` → managed Lucee.
|
||||||
|
|
||||||
|
### Проблемы, которые Opus обнаружил
|
||||||
|
- **`chat.cfm` сломан** — ссылается на таблицу `spec_rows`, которой нет (правильная — `spec_current`).
|
||||||
|
- **`llm_prompt.py` зависит от Lucee** — `_fetch_prompt()` ходит в `contractor.luceek8s.../prompt.cfm`. Для изолированного стека надо переключить на `db.prompts.get_active()`.
|
||||||
|
- **Нет `schema.sql`** — прод-БД `baza` создавалась руками. Для новой БД нужен `pg_dump --schema-only`.
|
||||||
|
- **Рестарт через nohup**, а не systemd (хотя память говорит про `contracts.service`).
|
||||||
|
|
||||||
|
### План Opus (6 фаз)
|
||||||
|
| Фаза | Что |
|
||||||
|
|---|---|
|
||||||
|
| Ф0 | Предпосылки: DNS, дамп схемы БД, managed-домен морды, LLM_KEY |
|
||||||
|
| Ф1 | Бэкенд на ВМ: `~/contracts-flask/`, порт 8777, БД `contracts_flask`, systemd, разрыв связи с Lucee |
|
||||||
|
| Ф2 | Nginx + SSL: новый server-блок `check.kube5s.ru` → :8777, certbot |
|
||||||
|
| Ф3 | JS-фронтенд: копии 6 JS-файлов, `VM_API='https://check.kube5s.ru'`, раздача через `/static/` |
|
||||||
|
| Ф4 | Морда на managed Flask: очистить `contracts-app`, портировать `index.cfm` в Jinja2, Dockerfile |
|
||||||
|
| Ф5 | Чат: перенести на ВМ как `POST /chat` (Python), контекст из `spec_current` |
|
||||||
|
| Ф6 | Проверка изоляции и сквозной тест |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что Opus НЕ знал (выяснили позже)
|
||||||
|
|
||||||
|
### 1. `contractor/deploy/` — ПОБАЙТОВОЕ зеркало ВМ
|
||||||
|
Opus предполагал это, но не знал точно. **Мы проверили md5 всех файлов** — ВСЕ файлы в `db/` и `services/` совпадают с ВМ (кроме `services/llm.py`, который отличается). Корневые `classify.py`, `grouping.py`, `prompts.py`, `unzip.py` на ВМ — мусор, не используются.
|
||||||
|
|
||||||
|
### 2. `contracts-app` — отдельный git-репозиторий
|
||||||
|
Opus предлагал «очистить contracts-app». Мы его случайно удалили вместе с `.git`. Репо: `https://gitea.services.ngcloud.ru/Nail/contracts-app`. Потом Наиль сказал «в ПИЗДУ его» и дал новый пустой репо: `https://gitea.services.ngcloud.ru/Nail/contracts-flask.git`.
|
||||||
|
|
||||||
|
### 3. `check.kube5s.ru` — DNS есть, nginx нет
|
||||||
|
DNS указывает на 5.172.178.213, но nginx не знает про этот домен → проваливается в default-сервер (obdai.ru), показывает чужой LLM-UI.
|
||||||
|
|
||||||
|
### 4. Рестарт — и nohup, и systemd
|
||||||
|
На ВМ реально перезапуск идёт через `pkill -f convert_server.py; nohup python3 ... &` (из sync.sh). Systemd-сервис `contracts.service` упоминается в памяти, но неясно, активен ли он.
|
||||||
|
|
||||||
|
### 5. JS-файлы уже на ВМ
|
||||||
|
6 JS-файлов лежат в `~/contracts/` (корень) и раздаются бэкендом через `/static/`. Для нового стека их надо скопировать в `~/contracts-flask/` и поменять `VM_API`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Моё мнение о плане Opus
|
||||||
|
|
||||||
|
### Что хорошо
|
||||||
|
- **Полная изоляция** — новая папка, новый порт, новая БД, новый systemd-юнит. Прод не заденешь.
|
||||||
|
- **Разрыв связи с Lucee** — правильно замечено, критично.
|
||||||
|
- **schema.sql через pg_dump** — правильно, ручное воссоздание схемы гарантирует ошибки.
|
||||||
|
- **6 фаз с зависимостями** — логичный порядок.
|
||||||
|
|
||||||
|
### Что плохо / упущено
|
||||||
|
1. **Ф4 (Flask-морда) завязана на CI/CD managed-платформы**, которую Opus не знает. План деплоя Flask-морды — «Dockerfile + gitea CI/CD» — слишком общий. Нужны конкретные шаги под Nubes managed Python.
|
||||||
|
2. **Не учтён `services/llm.py`** — единственный файл, который реально расходится между ВМ и репой. При копировании бэкенда надо брать версию из репы (она новее?) или с ВМ — надо выяснить.
|
||||||
|
3. **JS-файлы** — Opus предлагает копировать, но не уточняет что `VM_API` зашит в `app.js` (строка 3). Это единственное место, которое надо менять.
|
||||||
|
4. **Чат (Ф5)** — предлагает перенести на ВМ, но `chat.cfm` в Lucee всё равно сломан. Имеет смысл сделать чат сразу правильно, а не портировать сломанное.
|
||||||
|
5. **Нет упоминания `tests.js`** — на ВМ этого файла нет, но в репе есть. Надо включить в новый стек.
|
||||||
|
|
||||||
|
### Вердикт
|
||||||
|
План **рабочий, но требует уточнений** по пунктам выше. Главная ценность — Opus правильно понял архитектуру и предложил изоляцию. Детали (managed-деплой, `llm.py`, чат) надо доработать.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Текущее состояние (на 2026-06-27)
|
||||||
|
|
||||||
|
- ✅ DNS `check.kube5s.ru` → 5.172.178.213
|
||||||
|
- ✅ Пустой репо `contracts-flask` (`.gitignore` создан)
|
||||||
|
- ✅ `contractor/deploy/` подтверждён как зеркало ВМ
|
||||||
|
- ✅ Мусорные файлы на ВМ помечены
|
||||||
|
- ✅ Документация `vm-layout.md` создана
|
||||||
|
- ❌ Nginx для `check.kube5s.ru` не настроен
|
||||||
|
- ❌ Нет дампа схемы БД
|
||||||
|
- ❌ Не выбран managed-домен для Flask-морды
|
||||||
|
- ❌ Не выяснено, какая версия `services/llm.py` правильная (ВМ или репа)
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
# Ответ Опуса — 30 прицельных тестовых кейсов
|
||||||
|
|
||||||
|
Ответ на `History/opus-testcases-request.md` от 25.06.2026.
|
||||||
|
|
||||||
|
Опус изучил реальный код пайплайна (classify → group → compare) и дал 30 кейсов,
|
||||||
|
заточенных под конкретные уязвимости реализации.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Моя оценка
|
||||||
|
|
||||||
|
### Сильные стороны
|
||||||
|
|
||||||
|
1. **Опус реально читал код.** Он нашёл `_smart_extract` (первые 1500 символов + regex-маркеры),
|
||||||
|
`normalize_number` (диапазон `А-Я` не включает `Ё`), `new_values` содержит ТОЛЬКО изменённые поля.
|
||||||
|
Это не общие рекомендации — это точечные удары по слабым местам.
|
||||||
|
|
||||||
|
2. **Кейсы 13-14 — золото.** Кириллическая `О` vs ноль `0`, латинская `C` vs кириллическая `С` —
|
||||||
|
это реально ломает группировку. Опус предлагает их как «баг-детекторы»: не исправлять,
|
||||||
|
а задокументировать текущее поведение и ждать fuzzy-нормализации.
|
||||||
|
|
||||||
|
3. **Кейс 15 (Ё)** — я даже не знал про диапазон `А-Я`. Опус нашёл.
|
||||||
|
|
||||||
|
4. **Приоритет:** Блок B (group) → Блок C (compare) → Блок A (classify). Правильно —
|
||||||
|
group ломается детерминированно без LLM, баги воспроизводимы.
|
||||||
|
|
||||||
|
5. **Кейс 9 (слепая зона)** — 1500 символов выжимки. Практически важный кейс,
|
||||||
|
может объяснить почему некоторые файлы не классифицируются.
|
||||||
|
|
||||||
|
### Что можно добавить
|
||||||
|
|
||||||
|
- **Кейс на batch-progress при падении воркера:** если `classify_worker` упал на середине,
|
||||||
|
progress застревает на N/69 и никогда не достигнет total. Фронт висит вечно.
|
||||||
|
|
||||||
|
- **Кейс на два classify подряд с одним batch_id:** наш lock-файл должен вернуть 409.
|
||||||
|
Стоит проверить что второй запрос действительно отклоняется.
|
||||||
|
|
||||||
|
### Итого
|
||||||
|
|
||||||
|
30 кейсов покрывают все три шага пайплайна. ~40% кейсов — group (самый хрупкий),
|
||||||
|
~30% — compare (LLM-зависимый), ~30% — classify.
|
||||||
|
Можно брать в реализацию. Я генерирую docx по этим шаблонам.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Исходный ответ Опуса
|
||||||
|
|
||||||
|
*Далее — полный текст ответа Опуса без сокращений.*
|
||||||
|
|
||||||
|
|
||||||
|
### Кейс 1: Эталонный договор (baseline)
|
||||||
|
**Что проверяем:** базовое извлечение всех 6 полей из чистого договора.
|
||||||
|
**Почему может сломаться:** если падает даже это — проблема не в данных, а в промпте/парсинге.
|
||||||
|
**Файлы:** договор-XXX001-03700.docx
|
||||||
|
**Ключевой текст договора:** "Договор № XXX001-03700 на оказание технологических услуг от 15 марта 2025 г. ООО «Облако-Сервис» (Исполнитель)…"
|
||||||
|
**Ожидаем:** doc_type=contract, own_number="XXX001-03700", parent_number=null, doc_date="2025-03-15", counterparty="ООО «Облако-Сервис»", confidence=ok
|
||||||
|
|
||||||
|
### Кейс 2: Номер кириллицей
|
||||||
|
**Что проверяем:** own_number с кириллическим префиксом и слешем.
|
||||||
|
**Почему может сломаться:** LLM может «перевести» кириллицу в латиницу или отбросить год после слеша.
|
||||||
|
**Файлы:** договор-МЭС.docx
|
||||||
|
**Ключевой текст договора:** "Договор № МЭС-123/2024 от 10.01.2024 г."
|
||||||
|
**Ожидаем:** own_number="МЭС-123/2024" дословно (важно для парного group-кейса 12).
|
||||||
|
|
||||||
|
### Кейс 3: Нестандартный заголовок (не слово «Договор»)
|
||||||
|
**Что проверяем:** определение doc_type=contract, когда документ называется иначе.
|
||||||
|
**Почему может сломаться:** LLM привязывается к слову «Договор»; «Соглашение об оказании услуг» может уехать в other.
|
||||||
|
**Файлы:** договор-нестандарт.docx
|
||||||
|
**Ключевой текст договора:** "СОГЛАШЕНИЕ об оказании услуг связи № SVC-77 от 01.02.2025"
|
||||||
|
**Ожидаем:** doc_type=contract (а не other).
|
||||||
|
|
||||||
|
### Кейс 4: Допник с явным родителем
|
||||||
|
**Что проверяем:** разделение own_number и parent_number.
|
||||||
|
**Почему может сломаться:** LLM путает «свой» номер ДС и номер базового договора местами.
|
||||||
|
**Файлы:** допник-1-XXX003-01300_2.docx
|
||||||
|
**Ключевой текст договора:** "Дополнительное соглашение № 1 к Договору № XXX003-01300 от 05.06.2024"
|
||||||
|
**Ожидаем:** doc_type=supplement, own_number="1", parent_number="XXX003-01300".
|
||||||
|
|
||||||
|
### Кейс 5: Спецификация как отдельный файл
|
||||||
|
**Что проверяем:** doc_type=specification и привязка parent_number.
|
||||||
|
**Почему может сломаться:** спека без слова «договор» в шапке → other; parent потеряется.
|
||||||
|
**Файлы:** спецификация-XXX001-03700.docx
|
||||||
|
**Ключевой текст договора:** "Спецификация № 1 к Договору № XXX001-03700"
|
||||||
|
**Таблица спеки:** № / Наименование / Цена / Объём / Сумма / Дата.
|
||||||
|
**Ожидаем:** doc_type=specification, parent_number="XXX001-03700".
|
||||||
|
|
||||||
|
### Кейс 6: Договор БЕЗ контрагента в шапке
|
||||||
|
**Что проверяем:** поведение, когда counterparty не извлекается.
|
||||||
|
**Почему может сломаться:** LLM «галлюцинирует» контрагента или ставит реквизиты вместо названия.
|
||||||
|
**Файлы:** договор-без-стороны.docx
|
||||||
|
**Ключевой текст договора:** "Договор № NC-09 от 03.03.2025 на оказание услуг" (стороны — только в конце документа, см. кейс 9).
|
||||||
|
**Ожидаем:** counterparty=null/"" , confidence=low (а не выдуманное ООО).
|
||||||
|
|
||||||
|
### Кейс 7: Дата прописью и в нестандартном формате
|
||||||
|
**Что проверяем:** нормализацию doc_date → YYYY-MM-DD.
|
||||||
|
**Почему может сломаться:** «пятнадцатое марта две тысячи двадцать пятого года» или «15.03.25» (двузначный год).
|
||||||
|
**Файлы:** договор-дата-прописью.docx
|
||||||
|
**Ключевой текст договора:** "Договор № DT-15 от «пятнадцатого» марта 2025 года"
|
||||||
|
**Ожидаем:** doc_date="2025-03-15".
|
||||||
|
|
||||||
|
### Кейс 8: Несколько дат в шапке (дата vs срок действия)
|
||||||
|
**Что проверяем:** выбор ПРАВИЛЬНОЙ даты (дата заключения, а не «действует до»).
|
||||||
|
**Почему может сломаться:** LLM хватает первую попавшуюся дату.
|
||||||
|
**Файлы:** договор-две-даты.docx
|
||||||
|
**Ключевой текст договора:** "Договор № TD-21 от 01.04.2025, действует до 31.12.2026"
|
||||||
|
**Ожидаем:** doc_date="2025-04-01".
|
||||||
|
|
||||||
|
### Кейс 9: Реквизиты за пределами первых 1500 символов
|
||||||
|
**Что проверяем:** «слепую зону» _smart_extract.
|
||||||
|
**Почему может сломаться:** номер/контрагент стоят после длинной преамбулы (>1500 симв.) и далеко от regex-маркеров → в выжимку не попадут.
|
||||||
|
**Файлы:** договор-длинная-преамбула.docx
|
||||||
|
**Ключевой текст договора:** первые 2 страницы — общие положения без слова «№»; и только потом "Договор № LATE-99 … ООО «Поздний Контрагент»".
|
||||||
|
**Ожидаем:** документ должен классифицироваться (маркер №/договор рядом с данными). Если падает — это сигнал расширить окно выжимки.
|
||||||
|
|
||||||
|
### Кейс 10: «Шумный» документ — несколько номеров на странице
|
||||||
|
**Что проверяем:** выбор own_number среди нескольких «№».
|
||||||
|
**Почему может сломаться:** в шапке есть «Исх. № 456», «Лиц. № 789» и сам «Договор № MN-01» → LLM берёт чужой номер.
|
||||||
|
**Файлы:** договор-много-номеров.docx
|
||||||
|
**Ключевой текст договора:** "Исх. № 456 от 12.05.2025 … Лицензия № 789 … ДОГОВОР № MN-01 от 12.05.2025"
|
||||||
|
**Ожидаем:** own_number="MN-01".
|
||||||
|
|
||||||
|
### Кейс 11: doc_type=other (мусорный файл)
|
||||||
|
**Что проверяем:** что не-договор уходит в other, а не натягивается на contract.
|
||||||
|
**Почему может сломаться:** LLM «обязательно» хочет найти договор.
|
||||||
|
**Файлы:** акт-сверки.docx
|
||||||
|
**Ключевой текст договора:** "Акт сверки взаимных расчётов за 1 квартал 2025"
|
||||||
|
**Ожидаем:** doc_type=other, confidence=low.
|
||||||
|
|
||||||
|
### Кейс 12: Разделители — нормализуются (позитив)
|
||||||
|
**Что проверяем:** «МЭС-123/2024» и «МЭС 123/2024» → одна группа.
|
||||||
|
**Почему может сломаться:** baseline нормализации; обе дают МЭС1232024.
|
||||||
|
**Файлы:** договор-МЭС.docx (own="МЭС-123/2024") + допник-МЭС.docx (parent="МЭС 123/2024")
|
||||||
|
**Ожидаем:** документы в ОДНОЙ группе.
|
||||||
|
|
||||||
|
### Кейс 13: Кириллическая «О» против нуля «0» (классическая опечатка)
|
||||||
|
**Что проверяем:** «O3700» с кириллической О против «03700» с нулём.
|
||||||
|
**Почему может сломаться:** код НЕ приравнивает кириллицу к цифрам → О3700 ≠ 03700 → допник осиротеет в __unresolved__.
|
||||||
|
**Файлы:** договор.docx (own="XXX001-03700", цифра ноль) + допник.docx (parent="XXX001-О3700", кириллическая О)
|
||||||
|
**Ожидаем (как баг-детектор):** сейчас попадут в РАЗНЫЕ группы. Кейс фиксирует поведение и проверяет, появится ли fuzzy-нормализация.
|
||||||
|
|
||||||
|
### Кейс 14: Латинская «C» против кириллической «С»
|
||||||
|
**Что проверяем:** визуально одинаковые префиксы из разных алфавитов.
|
||||||
|
**Почему может сломаться:** normalize_number сохраняет оба алфавита → CBC-10 (лат) ≠ СВС-10 (кир).
|
||||||
|
**Файлы:** договор.docx (own="CBC-10", латиница) + допник.docx (parent="СВС-10", кириллица)
|
||||||
|
**Ожидаем (баг-детектор):** разные группы. Маркер необходимости юникод-конфьюзабл нормализации.
|
||||||
|
|
||||||
|
### Кейс 15: Буква «Ё» в номере
|
||||||
|
**Что проверяем:** диапазон А-Я не включает Ё.
|
||||||
|
**Почему может сломаться:** normalize_number("ЁЖ-5")="Ж5" — буква Ё выпадает. Если в одном документе «ЁЖ-5», в другом «ЖЕ-5» — рассинхрон.
|
||||||
|
**Файлы:** договор.docx (own="ЁЖ-5") + допник.docx (parent="ЁЖ-5")
|
||||||
|
**Ожидаем:** оба теряют Ё одинаково → совпадут как Ж5 (позитив, но по «неправильной» причине — кейс это документирует).
|
||||||
|
|
||||||
|
### Кейс 16: parent_number отсутствует у допника
|
||||||
|
**Что проверяем:** ветку «осиротевших» документов.
|
||||||
|
**Почему может сломаться:** допник без parent и без own-номера уходит в __unresolved__.
|
||||||
|
**Файлы:** допник-без-родителя.docx
|
||||||
|
**Ключевой текст договора:** "Дополнительное соглашение к договору оказания услуг" (без номеров вообще)
|
||||||
|
**Ожидаем:** документ в группе __unresolved__, не приклеен к случайному договору.
|
||||||
|
|
||||||
|
### Кейс 17: Допник ссылается на own_number, а не parent
|
||||||
|
**Что проверяем:** ветку матчинга «parent==c_norm ИЛИ own==c_norm».
|
||||||
|
**Почему может сломаться:** если LLM записал номер базового договора в own_number допника (а parent=null), группировка всё равно должна склеить.
|
||||||
|
**Файлы:** договор.docx (own="GR-50") + допник.docx (own="GR-50", parent=null)
|
||||||
|
**Ожидаем:** одна группа (срабатывает ветка own==own).
|
||||||
|
|
||||||
|
### Кейс 18: Два РАЗНЫХ договора с одинаковым нормализованным номером
|
||||||
|
**Что проверяем:** коллизию якорей групп.
|
||||||
|
**Почему может сломаться:** «AB-12» и «A-B12» → оба AB12; допник приклеится не к тому/к обоим.
|
||||||
|
**Файлы:** договор-A.docx (own="AB-12") + договор-B.docx (own="A-B12") + допник.docx (parent="AB12")
|
||||||
|
**Ожидаем:** видно недетерминированность/двойную привязку — кейс ловит коллизии нормализации.
|
||||||
|
|
||||||
|
### Кейс 19: Семья из 4 документов, разный порядок дат
|
||||||
|
**Что проверяем:** сортировку внутри группы по doc_date и метку initial/additional.
|
||||||
|
**Почему может сломаться:** если даты парсятся криво, «initial» может стать не самый ранний документ.
|
||||||
|
**Файлы:** договор(2025-01-10) + допник-2(2025-05-01) + спека(2025-02-01) + допник-1(2025-03-01)
|
||||||
|
**Ожидаем:** порядок initial=договор, далее по возрастанию даты; type первого = initial.
|
||||||
|
|
||||||
|
### Кейс 20: Допник с лишним суффиксом-копией в имени файла
|
||||||
|
**Что проверяем:** что нормализуется НОМЕР, а не имя файла.
|
||||||
|
**Почему может сломаться:** имя «допник-1-XXX003-01300_2.docx» содержит _2 (копия), это не должно влиять на own_number/parent.
|
||||||
|
**Файлы:** допник-1-XXX003-01300_2.docx
|
||||||
|
**Ключевой текст договора:** "Дополнительное соглашение № 1 к Договору № XXX003-01300"
|
||||||
|
**Ожидаем:** own_number="1", parent_number="XXX003-01300"; _2 игнорируется.
|
||||||
|
|
||||||
|
### Кейс 21: Цена изменилась на 1 копейку
|
||||||
|
**Что проверяем:** чувствительность UPDATE к микроизменению.
|
||||||
|
**Почему может сломаться:** LLM сочтёт разницу «несущественной» и не выдаст UPDATE; или округлит.
|
||||||
|
**Файлы:** спека-v1.docx + допник-цена.docx
|
||||||
|
**Таблица спеки (current):** Аренда стойко-места | 50000.00 | 1 | 50000.00 | 2025-01-01
|
||||||
|
**Текст допника:** "С 01.03.2025 стоимость аренды устанавливается 50 000,01 руб."
|
||||||
|
**Ожидаем:** UPDATE r1 new_values={price:50000.01, sum:50000.01, date_start:"2025-03-01"}.
|
||||||
|
|
||||||
|
### Кейс 22: Объём с 3 на 0 — это UPDATE или DELETE?
|
||||||
|
**Что проверяем:** трактовку «количество стало нулём».
|
||||||
|
**Почему может сломаться:** граница UPDATE(qty=0) vs DELETE; разные модели решают по-разному.
|
||||||
|
**Файлы:** спека-v1.docx + допник-обнуление.docx
|
||||||
|
**Таблица спеки (current):** IP-адрес IPv4 | 300 | 3 | 900 | 2025-01-01
|
||||||
|
**Текст допника:** "С 01.04.2025 услуга предоставления IP-адресов исключается (количество — 0)."
|
||||||
|
**Ожидаем (фиксируем решение):** один из {DELETE r1} ИЛИ {UPDATE r1 qty=0,sum=0}. Кейс закрепляет ожидаемую трактовку и ловит непостоянство.
|
||||||
|
|
||||||
|
### Кейс 23: Услуга переименована, суть та же
|
||||||
|
**Что проверяем:** семантический матч UPDATE по смыслу, а не по символам.
|
||||||
|
**Почему может сломаться:** LLM не свяжет «Аренда стойко-места» и «Размещение оборудования в стойке» → выдаст ADD+DELETE вместо UPDATE.
|
||||||
|
**Файлы:** спека-v1.docx + допник-переименование.docx
|
||||||
|
**Таблица спеки (current):** Аренда стойко-места | 50000 | 1 | 50000 | 2025-01-01
|
||||||
|
**Текст допника:** "Услугу «Размещение оборудования в стойке» с 01.05.2025 — 52 000 руб."
|
||||||
|
**Ожидаем:** UPDATE r1 (а не ADD новой + DELETE старой).
|
||||||
|
|
||||||
|
### Кейс 24: Полная замена приложения (full_replace)
|
||||||
|
**Что проверяем:** триггер mode=full_replace по фразе «изложить в следующей редакции».
|
||||||
|
**Почему может сломаться:** LLM попытается diff'ить построчно (partial) вместо того, чтобы выдать все строки как ADD.
|
||||||
|
**Файлы:** спека-v1.docx + допник-новая-редакция.docx
|
||||||
|
**Таблица спеки (current):** r1 Аренда | 50000 | 1 | 50000; r2 IP | 300 | 8 | 2400
|
||||||
|
**Текст допника:** "Приложение № 1 изложить в следующей редакции:" + новая таблица (Аренда 55000; IP 12 шт; +Резервное копирование 4000).
|
||||||
|
**Ожидаем:** mode=full_replace, ВСЕ строки новой редакции как ADD, без UPDATE/DELETE.
|
||||||
|
|
||||||
|
### Кейс 25: Добавление новой услуги (чистый ADD)
|
||||||
|
**Что проверяем:** распознавание строки, которой не было.
|
||||||
|
**Почему может сломаться:** LLM попробует «прицепить» к похожей существующей через UPDATE.
|
||||||
|
**Файлы:** спека-v1.docx + допник-добавление.docx
|
||||||
|
**Таблица спеки (current):** Аренда | 50000 | 1 | 50000
|
||||||
|
**Текст допника:** "С 01.06.2025 добавить услугу «Резервное копирование 1 ТБ» — 4 000 руб./мес., 1 шт."
|
||||||
|
**Ожидаем:** ADD new_row={name:"Резервное копирование 1 ТБ", price:4000, qty:1, sum:4000, date_start:"2025-06-01"}.
|
||||||
|
|
||||||
|
### Кейс 26: Удаление услуги (чистый DELETE)
|
||||||
|
**Что проверяем:** корректный target_id при удалении.
|
||||||
|
**Почему может сломаться:** LLM удалит не ту строку (перепутает r1/r2) или выдаст UNRESOLVED.
|
||||||
|
**Файлы:** спека-v1.docx + допник-удаление.docx
|
||||||
|
**Таблица спеки (current):** r1 Аренда | 50000 | 1 | 50000; r2 Мониторинг | 2000 | 1 | 2000
|
||||||
|
**Текст допника:** "С 01.07.2025 услуга «Мониторинг 24/7» исключается из спецификации."
|
||||||
|
**Ожидаем:** DELETE r2 (именно r2).
|
||||||
|
|
||||||
|
### Кейс 27: Изменение только суммы при тех же цене×объёме (ловушка консистентности)
|
||||||
|
**Что проверяем:** что LLM не «досчитывает» поля, которых нет в допнике.
|
||||||
|
**Почему может сломаться:** допник меняет только qty, а LLM забывает пересчитать sum (или наоборот, лезет в price).
|
||||||
|
**Файлы:** спека-v1.docx + допник-объём.docx
|
||||||
|
**Таблица спеки (current):** IP-адрес | 300 | 8 | 2400 | 2025-01-01
|
||||||
|
**Текст допника:** "Увеличить количество IP-адресов до 12 (с 01.08.2025)."
|
||||||
|
**Ожидаем:** UPDATE r1 new_values={qty:12, sum:3600, date_start:"2025-08-01"} — price НЕ в new_values.
|
||||||
|
|
||||||
|
### Кейс 28: Допник меняет услугу, которой нет в спеке (UNRESOLVED)
|
||||||
|
**Что проверяем:** ветку UNRESOLVED.
|
||||||
|
**Почему может сломаться:** LLM «придумает» ADD вместо честного UNRESOLVED.
|
||||||
|
**Файлы:** спека-v1.docx + допник-призрак.docx
|
||||||
|
**Таблица спеки (current):** Аренда | 50000 | 1 | 50000
|
||||||
|
**Текст допника:** "Стоимость услуги «Услуга миграции данных» снизить на 10%." (такой услуги в спеке нет)
|
||||||
|
**Ожидаем:** UNRESOLVED reason="услуги нет в текущей спецификации".
|
||||||
|
|
||||||
|
### Кейс 29: Числа с пробелами-разделителями и запятой-десятичной
|
||||||
|
**Что проверяем:** парсинг «55 000,00» → 55000.0.
|
||||||
|
**Почему может сломаться:** LLM вернёт строку «55 000,00» или 55.0 (обрежет по запятой).
|
||||||
|
**Файлы:** спека-v1.docx + допник-формат-чисел.docx
|
||||||
|
**Таблица спеки (current):** Аренда | 50000 | 1 | 50000
|
||||||
|
**Текст допника:** "Цена аренды с 01.09.2025 — 55 000,00 руб."
|
||||||
|
**Ожидаем:** UPDATE r1 price=55000.0 (число, не строка).
|
||||||
|
|
||||||
|
### Кейс 30: Болтливый LLM-ответ / JSON в markdown (робастность парсера)
|
||||||
|
**Что проверяем:** устойчивость парсинга на стороне Python (compare и classify).
|
||||||
|
**Почему может сломаться:** ответ обёрнут в ```json ```, есть текст «Вот результат:», висячая запятая.
|
||||||
|
**Файлы:** любой простой допник (UPDATE одной цены) — суть в форме ответа, не в данных.
|
||||||
|
**Текст допника:** "Цена аренды — 51 000 руб. с 01.10.2025."
|
||||||
|
**Ожидаем:** парсер извлекает JSON из markdown-блока и применяет UPDATE r1.
|
||||||
|
|
||||||
|
### Матрица покрытия
|
||||||
|
|
||||||
|
| Аспект | Кейсы |
|
||||||
|
|---|---|
|
||||||
|
| classify: все поля / baseline | 1, 5 |
|
||||||
|
| classify: тип документа (contract/spec/other) | 3, 5, 11 |
|
||||||
|
| classify: own vs parent | 4, 17 |
|
||||||
|
| classify: дата | 7, 8 |
|
||||||
|
| classify: контрагент | 1, 6 |
|
||||||
|
| classify: слепая зона выжимки | 9, 10 |
|
||||||
|
| group: нормализация разделителей | 12, 15 |
|
||||||
|
| group: кириллица/латиница/цифры | 13, 14, 15 |
|
||||||
|
| group: сироты / unresolved | 16 |
|
||||||
|
| group: ветки матча и коллизии | 17, 18 |
|
||||||
|
| group: сортировка/порядок | 19, 20 |
|
||||||
|
| compare: UPDATE | 21, 23, 27, 29 |
|
||||||
|
| compare: ADD / DELETE | 22, 25, 26 |
|
||||||
|
| compare: full_replace | 24 |
|
||||||
|
| compare: UNRESOLVED | 22, 28 |
|
||||||
|
| robustness: JSON-парсинг | 30 |
|
||||||
|
|
||||||
|
**Рекомендация по приоритету:** сначала Блок B (кейсы 13–18) — там код ломается детерминированно и без LLM, баги воспроизводимы на 100%. Потом Блок C (LLM-логика), затем Блок A.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
|
||||||
|
Ты — Claude Opus. Тебе пишет разработчик.
|
||||||
|
|
||||||
|
**ВАЖНО:** Твой ответ — ТОЛЬКО текст в чат. Ты НЕ должен ничего редактировать, создавать файлы, писать код. Просто текстовый ответ с принципами и шаблонами. Файлы создам я сам.
|
||||||
|
|
||||||
|
У нас сервис сверки договоров. Pipeline: загрузка → LLM-классификация → группировка → LLM-сравнение.
|
||||||
|
Бэкенд: Python http.server + PostgreSQL. LLM: gpt-oss-120b через api.aillm.ru.
|
||||||
|
|
||||||
|
Реальные файлы лежат в `dogovora/примеры_договоров_для_ИИ/`:
|
||||||
|
- договор-XXX001-03700.docx — Договор на оказание технологических услуг
|
||||||
|
- допник-1-XXX003-01300_2.docx — Допсоглашение с таблицей услуг (6-7 колонок: №, Наименование, Цена, Объем, Сумма, Дата)
|
||||||
|
- спецификация-XXX001-03700.docx — Спецификация (таблица услуг)
|
||||||
|
|
||||||
|
Мне не нужны сгенерированные ТОБОЙ docx-файлы — это дорого.
|
||||||
|
|
||||||
|
Мне нужны ПРИНЦИПЫ + ТЕКСТОВЫЕ ШАБЛОНЫ для 25-30 тестовых кейсов. Каждый кейс проверяет один конкретный аспект:
|
||||||
|
- classify (как LLM извлекает тип/номер/дату/контрагента/родителя)
|
||||||
|
- group (как Python нормализует номера и группирует)
|
||||||
|
- compare (как LLM находит ADD/DELETE/UPDATE между версиями спеки)
|
||||||
|
|
||||||
|
Формат ответа — для каждого кейса:
|
||||||
|
```
|
||||||
|
### Кейс N: Название
|
||||||
|
**Что проверяем:** ...
|
||||||
|
**Почему может сломаться:** ...
|
||||||
|
**Файлы:** договор-X.docx + спека-X.docx
|
||||||
|
**Ключевой текст договора:** "Договор № ... от ..."
|
||||||
|
**Таблица спеки:** <структура с ценами/объёмами>
|
||||||
|
```
|
||||||
|
|
||||||
|
Файлы создам я сам через python-docx. Ты даёшь принципы и текст — я генерирую.
|
||||||
|
|
||||||
|
Примеры того ЧТО может пойти не так и что надо проверить:
|
||||||
|
- Номер договора кириллицей: "МЭС-123/2024"
|
||||||
|
- Два допника ссылаются на номер с опечаткой: "03700" vs "О3700"
|
||||||
|
- Спека без даты
|
||||||
|
- Договор без контрагента
|
||||||
|
- Нестандартный заголовок: "Соглашение об услугах" вместо "Договор"
|
||||||
|
- Compare: цена изменилась на 1 копейку, объём с 3 на 0, услуга переименована
|
||||||
|
|
||||||
|
Думай как тестировщик: что МОЖЕТ сломаться и как это поймать.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Резюме сессии — v1.0.177
|
||||||
|
|
||||||
|
## Текущее состояние
|
||||||
|
|
||||||
|
Проект: **contracts** — сверка договоров через LLM. Домен: colocation, ЦОД, аренда стоек.
|
||||||
|
|
||||||
|
- **Фронт:** index.cfm (Lucee, ручной деплой) + app.js (~50KB) + app_utils.js (~4KB) с ВМ (contracts.kube5s.ru)
|
||||||
|
- **Бэкенд:** Python http.server на ВМ (5.172.178.213:8766), systemd-сервис `contracts`
|
||||||
|
- **БД:** PostgreSQL, прямой доступ psycopg2
|
||||||
|
- **LLM:** gpt-oss-120b через api.aillm.ru
|
||||||
|
|
||||||
|
## Что сделано за сессию
|
||||||
|
|
||||||
|
### Аудит безопасности (Sonnet) — все CRITICAL/HIGH исправлены
|
||||||
|
- XSS ×5: escHtml на все данные от LLM
|
||||||
|
- Path traversal: os.path.basename(), лимит 100MB
|
||||||
|
- UUID валидация: contract_id, batch_id, doc_id
|
||||||
|
- FK cascade: spec_current точечно, не весь контракт
|
||||||
|
- Race conditions: seq (FOR UPDATE + транзакция), prompts (seed per-role)
|
||||||
|
- MITM: verify=False убран
|
||||||
|
|
||||||
|
### Функционал
|
||||||
|
- **Виртуальные группы:** группировка без базового договора (по parent_number/own_number)
|
||||||
|
- **Промежуточные результаты:** клик на имя файла → 📤 текст в LLM, 📥 ответ, 📄 распарсенный JSON
|
||||||
|
- **Сравнение в карточке:** раскрывается внутри группы, не в конце страницы
|
||||||
|
- **Два состояния групп:** необработанная (кнопка) vs обработанная (✓ Готово, только сворачивание)
|
||||||
|
- **Одно сравнение одновременно:** все кнопки блокируются, предыдущий ES закрывается
|
||||||
|
- **Stepper:** ○ Загрузка → ○ Классификация → ○ Группировка → ○ Сравнение в топбаре
|
||||||
|
- **«О сервисе»:** полная инструкция
|
||||||
|
|
||||||
|
### Организация
|
||||||
|
- `history/` + `History/` → `History/{sessions,llm-analysis,architecture,features,topics}`
|
||||||
|
- Ветка `v1.0.177-stable` сохранена
|
||||||
|
|
||||||
|
## Ключевые файлы для контекста
|
||||||
|
|
||||||
|
- `contractor/deploy/app.js` — фронтенд (~1040 строк)
|
||||||
|
- `contractor/deploy/app_utils.js` — утилиты
|
||||||
|
- `contractor/index.cfm` — HTML (v1.0.177, ?v=1.0.177 для cache bust)
|
||||||
|
- `contractor/deploy/convert_server.py` — бэкенд-роутер
|
||||||
|
- `contractor/deploy/db/*.py` — CRUD
|
||||||
|
- `contractor/deploy/services/*.py` — логика
|
||||||
|
|
||||||
|
## План на следующий чат
|
||||||
|
|
||||||
|
**Рефакторинг: store + render.** Файл: `History/llm-analysis/decoupling-final-plan.md`
|
||||||
|
|
||||||
|
Фаза 0 (ближайшая): ввести `state`, `render(state)`, соглашение «мутировал → render()». Без смены поведения.
|
||||||
|
|
||||||
|
## Правила (из памяти)
|
||||||
|
|
||||||
|
- ⛔ Без «делай» — ничего не делать
|
||||||
|
- ⛔ После каждого изменения — проверка (node -c / py_compile)
|
||||||
|
- ⛔ После каждой правки — коммит
|
||||||
|
- ⛔ Lucee — ручной деплой (не автоматический)
|
||||||
|
- ⛔ JS + Python — деплой через scp на ВМ
|
||||||
|
- ⛔ Оба репо коммитить: contracts (родительский) + contractor (подмодуль)
|
||||||
@@ -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,132 @@
|
|||||||
|
# Sonnet Review: план миграции ВМ → Flask — анализ и ответ
|
||||||
|
|
||||||
|
**Дата:** 2026-07-14
|
||||||
|
**Источник:** Sonnet (Claude) — ревью `History/migration-vm-to-flask-plan-2026-07-14.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Резюме Соннета
|
||||||
|
|
||||||
|
План **одобрен архитектурно**. Критичных блокеров — 2, важных дополнений — 3, улучшений — 5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Критичные проблемы (Соннет)
|
||||||
|
|
||||||
|
### 1. `site/` — конфликт с Python stdlib ⛔
|
||||||
|
|
||||||
|
> `from site.db import ...` — `site` это встроенный модуль Python. При запуске не из корня Python найдёт встроенный `site`, а не локальный.
|
||||||
|
|
||||||
|
**Моё мнение:** ✅ **Согласен полностью.** Это реальная проблема. `site` — built-in модуль Python, импортируется при старте интерпретатора. Если `PYTHONPATH` или `sys.path` поставит корень раньше чем `site-packages` — получим наш пакет. Если наоборот — stdlib. Нестабильно.
|
||||||
|
|
||||||
|
**Решение:** переименовать `site/` → `app/` или `contracts_app/`. Я за `app/` — короче, семантически понятно (Flask application factory), не конфликтует ни с чем.
|
||||||
|
|
||||||
|
### 2. `/api/cleanup` — нулевая аутентификация ⛔
|
||||||
|
|
||||||
|
> Любой может POST /api/cleanup → потеря всех данных.
|
||||||
|
|
||||||
|
**Моё мнение:** ✅ **Согласен.** Но с нюансом. Текущий `convert_server.py` тоже имеет этот эндпоинт без auth — и он на внешнем домене `contracts.kube5s.ru`. Так что это не регресс, а существующая дыра.
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
- Минимум: `X-Api-Key` в заголовке, сверка с `os.environ["API_KEY"]`
|
||||||
|
- Средний: nginx `allow 127.0.0.1; deny all;` на этот location (если cleanup вызывается только с самого Flask-контейнера)
|
||||||
|
- Правильный: убрать cleanup вообще, заменить на автоочистку по TTL (cron/APScheduler)
|
||||||
|
|
||||||
|
Но это **отдельная задача**, не часть миграции. В рамках миграции — просто не ухудшить ситуацию.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Важные дополнения (Соннет)
|
||||||
|
|
||||||
|
### 3. Файловый lock для classify — хрупкий
|
||||||
|
|
||||||
|
> `/tmp/classify_{batch_id}.lock` — если Flask упадёт, lock остаётся навсегда.
|
||||||
|
|
||||||
|
**Моё мнение:** ✅ **Согласен.** Lock-файл — антипаттерн в stateless-приложении. Сейчас в `convert_server.py` та же схема (subprocess + lock-файл), и она работает только потому что ВМ не рестартит.
|
||||||
|
|
||||||
|
**Решение:** in-memory `dict[batch_id] → thread` + проверка `thread.is_alive()`. При старте Flask никаких lock-файлов нет. Лучше: store `classify_status='processing'` в БД и сбрасывать `processing → pending` при старте приложения.
|
||||||
|
|
||||||
|
### 4. SSE — нет heartbeat
|
||||||
|
|
||||||
|
> При медленном LLM nginx и браузеры режут соединение.
|
||||||
|
|
||||||
|
**Моё мнение:** ✅ **Согласен.** Добавить `yield ": heartbeat\n\n"` раз в 15 секунд. SSE-комментарий (строка начинается с `:`) игнорируется EventSource, но сбрасывает таймауты nginx/браузера.
|
||||||
|
|
||||||
|
### 5. Старые proxy-роуты — явно не сказано удалить
|
||||||
|
|
||||||
|
> В плане написано «создать новый app.py», но не сказано явно «удалить старые /api/* proxy-роуты».
|
||||||
|
|
||||||
|
**Моё мнение:** ✅ **Согласен, но это очевидно.** Новый `app.py` полностью заменяет старый — старых роутов `/chat`, `/api/prompts` (proxy на ВМ) не будет, потому что логика теперь локальная. Но в плане стоит написать явно: «удалить ВСЕ proxy-роуты, они больше не нужны».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Улучшения (Соннет)
|
||||||
|
|
||||||
|
### 6. `prompts_bp.py` и `pages_bp.py` — не раскрыты
|
||||||
|
|
||||||
|
**Моё мнение:** ✅ **Справедливо.** Но они тривиальны: `prompts_bp.py` = CRUD по таблице `prompts` (4 ручки), `pages_bp.py` = `render_template("index.html")` + `render_template("architect.html")`. Не расписывал детально потому что там нечего расписывать. Можно добавить один абзац для полноты.
|
||||||
|
|
||||||
|
### 7. `drhider/` — не упоминается
|
||||||
|
|
||||||
|
**Моё мнение:** ⚠️ **Справедливо, но осознанно.** `drhider/` — это отдельный сервис (обфускация персональных данных), не часть пайплайна сверки договоров. Он уже работает на Flask в `site/services/drhider.py` и `site/routes/drhider_bp.py`. В план миграции сверки он не входит — это другой сервис на том же хосте. Но стоит упомянуть это явно: «drhider не трогаем, он уже на Flask».
|
||||||
|
|
||||||
|
### 8. `repository.py` — не объяснено как расширить
|
||||||
|
|
||||||
|
**Моё мнение:** ⚠️ **Справедливо, но слишком рано.** Protocol-паттерн в `repository.py` уже есть, `PgRepository` частично реализован. Полное внедрение DI через репозиторий во все services — это Фаза 2, после того как базовая миграция заработает. Сейчас services используют `from site.db import documents` напрямую, и это ОК для первого шага. Переписывать всё на DI сразу = риск сломать работающий код.
|
||||||
|
|
||||||
|
**План:** после миграции — отдельная задача «внедрить Repository DI во все services».
|
||||||
|
|
||||||
|
### 9. Graceful shutdown — in-flight classify потоки
|
||||||
|
|
||||||
|
**Моё мнение:** ✅ **Справедливо.** При `docker stop` daemon-потоки убиваются, документы застревают в `classify_status='processing'`.
|
||||||
|
|
||||||
|
**Решение (уже в коде ВМ!):** `db/documents.reset_classify_status(batch_id)` сбрасывает `processing → pending`. Добавить вызов в `app.py` при старте:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# При старте: сбросить все застрявшие processing → pending
|
||||||
|
from site.db.connection import execute
|
||||||
|
execute("UPDATE documents SET classify_status='pending', error_message=NULL WHERE classify_status='processing'")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Тесты — только curl, нет pytest
|
||||||
|
|
||||||
|
**Моё мнение:** ✅ **Справедливо.** В репо уже есть `tests/` с `conftest.py`. Нужен шаг «адаптировать тесты под `app.test_client()`». Но это **не блокер для миграции** — тесты можно дописать после.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Моя итоговая оценка
|
||||||
|
|
||||||
|
| Категория | Пунктов | Критичность |
|
||||||
|
|-----------|---------|-------------|
|
||||||
|
| Критичные (надо исправить ДО миграции) | 2 | `site/` → `app/`, auth на cleanup |
|
||||||
|
| Важные (надо добавить в план) | 3 | lock→in-memory, heartbeat, явное удаление proxy |
|
||||||
|
| Улучшения (можно после миграции) | 5 | prompts/pages детали, drhider, repository DI, graceful, тесты |
|
||||||
|
|
||||||
|
### Что делаем ДО начала миграции:
|
||||||
|
|
||||||
|
1. **Переименовать `site/` → `app/`** — везде в плане, во всех импортах
|
||||||
|
2. **Добавить auth на `/api/cleanup`** — `X-Api-Key` или `require_local`
|
||||||
|
|
||||||
|
### Что добавляем в план:
|
||||||
|
|
||||||
|
3. **in-memory lock** для classify вместо файлового
|
||||||
|
4. **SSE heartbeat** раз в 15 сек
|
||||||
|
5. **Упомянуть явно:** старые proxy-роуты удалить, drhider не трогать
|
||||||
|
6. **Graceful shutdown:** сброс `processing → pending` при старте
|
||||||
|
7. **prompts_bp / pages_bp** — один абзац что там
|
||||||
|
8. **repository DI** — отложить на пост-миграцию
|
||||||
|
|
||||||
|
### Что НЕ блокер:
|
||||||
|
|
||||||
|
- `drhider` — уже на Flask, не часть миграции
|
||||||
|
- Repository DI — Фаза 2
|
||||||
|
- pytest — после миграции
|
||||||
|
- prompts/pages — тривиальны
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Вердикт
|
||||||
|
|
||||||
|
План **рабочий**. Соннет подтвердил архитектурные решения (blueprints, разделение db/services/routes, генератор для SSE, DI). Критичные замечания — реальные и требуют правки плана. Остальное — улучшения надёжности, которые можно добавить в план сейчас или отложить.
|
||||||
|
|
||||||
|
**Готовность к миграции:** 90%. После правки 2 критичных пунктов → можно начинать.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Step 0: Критические фиксы перед миграцией
|
||||||
|
|
||||||
|
**Ветка:** `feature/flask-migration`
|
||||||
|
**Дата:** 2026-07-14
|
||||||
|
|
||||||
|
## Фикс 1: `site/` → `app/` (конфликт с Python stdlib)
|
||||||
|
|
||||||
|
`site` — встроенный модуль Python. `from site.db import ...` нестабильно.
|
||||||
|
|
||||||
|
Решение: `git mv contracts-flask/site contracts-flask/app`.
|
||||||
|
|
||||||
|
Импортов `from site ...` в коде НЕТ (проверено grep), только комментарий в app.js.
|
||||||
|
|
||||||
|
## Фикс 2: auth на `/api/cleanup`
|
||||||
|
|
||||||
|
Добавить `X-Api-Key` проверку. Значение из `os.environ["API_KEY"]`.
|
||||||
|
|
||||||
|
Оба фикса — ДО начала переноса кода.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Step 1: Перенос db/ → app/db/
|
||||||
|
|
||||||
|
**Ветка:** `feature/flask-migration` (submodule + parent)
|
||||||
|
**Дата:** 2026-07-14
|
||||||
|
|
||||||
|
## Что делаем
|
||||||
|
|
||||||
|
Копируем `deploy/db/*.py` → `app/db/`. Менять НИЧЕГО не надо — чистый CRUD.
|
||||||
|
|
||||||
|
## Файлы
|
||||||
|
|
||||||
|
- `deploy/db/connection.py` → `app/db/connection.py` — connection pool
|
||||||
|
- `deploy/db/documents.py` → `app/db/documents.py` — documents CRUD
|
||||||
|
- `deploy/db/contracts.py` → `app/db/contracts.py` — contracts CRUD
|
||||||
|
- `deploy/db/supplements.py` → `app/db/supplements.py` — supplements CRUD
|
||||||
|
- `deploy/db/spec_current.py` → `app/db/spec_current.py` — spec queries
|
||||||
|
- `deploy/db/spec_events.py` → `app/db/spec_events.py` — events CRUD
|
||||||
|
- `deploy/db/prompts.py` → `app/db/prompts.py` — prompts CRUD
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd contracts-flask
|
||||||
|
python3 -c "from app.db import documents; print('ok')"
|
||||||
|
```
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Step 2: Перенос compare/ → app/services/
|
||||||
|
|
||||||
|
**Ветка:** `feature/flask-migration`
|
||||||
|
**Дата:** 2026-07-14
|
||||||
|
|
||||||
|
## Что делаем
|
||||||
|
|
||||||
|
Копируем бизнес-логику из `deploy/compare/*.py` → `app/services/`.
|
||||||
|
|
||||||
|
## Переносим (чистая логика, без HTTP)
|
||||||
|
|
||||||
|
- `parse.py` — парсинг PDF/DOCX
|
||||||
|
- `classify.py` — LLM-классификация
|
||||||
|
- `grouping.py` — группировка документов
|
||||||
|
- `llm.py` — вызов LLM для сравнения
|
||||||
|
- `llm_client.py` — протокол + httpx + fake
|
||||||
|
- `metrics.py` — проверка арифметики
|
||||||
|
|
||||||
|
## НЕ переносим (привязаны к HTTP, будут переписаны в blueprints)
|
||||||
|
|
||||||
|
- `upload.py` — использует cgi.FieldStorage
|
||||||
|
- `unzip.py` — читает rfile.read()
|
||||||
|
- `process.py` — SSE через callback
|
||||||
|
|
||||||
|
## Правки импортов
|
||||||
|
|
||||||
|
Было → стало:
|
||||||
|
- `from db import documents` → `from app.db import documents`
|
||||||
|
- `from .parse import parse_file` → `from app.services.parse import parse_file`
|
||||||
|
- `from llm_prompt import ...` → `from app.llm_prompt import ...`
|
||||||
|
- `from .llm_client import ...` → `from app.services.llm_client import ...`
|
||||||
|
- `from .metrics import ...` → `from app.services.metrics import ...`
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Step 3: config.py + все blueprints + новый app.py
|
||||||
|
|
||||||
|
**Ветка:** `feature/flask-migration`
|
||||||
|
**Дата:** 2026-07-14
|
||||||
|
|
||||||
|
## Создаваемые файлы
|
||||||
|
|
||||||
|
- `app/config.py` — настройки
|
||||||
|
- `app/routes/__init__.py` — register_routes()
|
||||||
|
- `app/routes/upload_bp.py` — /upload, /convert-doc, /unzip-upload
|
||||||
|
- `app/routes/pipeline_bp.py` — /process-v2 (SSE), /classify-batch
|
||||||
|
- `app/routes/api_bp.py` — /api/* (groups, documents, supplements, sync, cleanup, spec-current)
|
||||||
|
- `app/routes/prompts_bp.py` — /api/prompts/*
|
||||||
|
- `app/routes/health_bp.py` — /health
|
||||||
|
- `app/routes/pages_bp.py` — /, /architect
|
||||||
|
- `app/services/process.py` — адаптированный process.py (callback → generator)
|
||||||
|
- `app/app.py` — новый (замена старого, без VM-прокси)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# Результаты тестирования — v1.0.178
|
||||||
|
|
||||||
|
Дата: 25.06.2026
|
||||||
|
|
||||||
|
## Сводка
|
||||||
|
|
||||||
|
| Уровень | Тестов | Результат |
|
||||||
|
|---------|--------|-----------|
|
||||||
|
| Юнит-тесты (Node.js) | **131** | **✅ 131/131 PASS** |
|
||||||
|
| `node -c` синтаксис | 6 файлов | ✅ чистый |
|
||||||
|
| API smoke (curl) | 8 эндпоинтов | ✅ HTTP 200 |
|
||||||
|
| Аудит кода | — | ✅ 4 бага исправлено |
|
||||||
|
|
||||||
|
## Запуск тестов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd contractor/deploy && node tests.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Тесты не требуют браузера — все зависимости замоканы (document, window, lucide, crypto, XMLHttpRequest).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Юнит-тесты (131 шт.)
|
||||||
|
|
||||||
|
### statusToHTML (21 тест)
|
||||||
|
Чистая функция: `{ kind, pct?, text?, count?, elapsed? }` → HTML.
|
||||||
|
|
||||||
|
| Группа | Тесты |
|
||||||
|
|--------|-------|
|
||||||
|
| Граничные | `null`, `undefined`, `{}`, `{ kind: '' }`, `{ kind: 'unknown' }` → пусто |
|
||||||
|
| `uploading` | pct=0, 75, 100, -5, без pct (→0%) |
|
||||||
|
| `uploaded` | базовая проверка |
|
||||||
|
| `unzipping` / `parsing` | текстовые статусы |
|
||||||
|
| `parsed` | count=5, 0; elapsed='2.3', '0', без elapsed |
|
||||||
|
| `error` | с текстом, без текста (→дефолт), с пустым текстом (→дефолт) |
|
||||||
|
|
||||||
|
### applyParseResult (13 тестов)
|
||||||
|
Чистая функция: применяет `{ status, element_count, error }` к entry.
|
||||||
|
|
||||||
|
| Группа | Тесты |
|
||||||
|
|--------|-------|
|
||||||
|
| `parsed` | с elapsed, без elapsed, element_count=0, без element_count |
|
||||||
|
| `error` | с текстом, без текста (→дефолт "ошибка парсинга") |
|
||||||
|
| `null` parsed | → `{ kind: 'error', text: 'Неизвестная ошибка' }` |
|
||||||
|
| Иммутабельность | старые поля entry не затираются |
|
||||||
|
|
||||||
|
### reconcileSelection (7 тестов)
|
||||||
|
Чистая функция: фильтрует `files` по именам из `newFiles`.
|
||||||
|
|
||||||
|
| Группа | Тесты |
|
||||||
|
|--------|-------|
|
||||||
|
| Норма | 3 файла → 2 осталось, порядок сохранён |
|
||||||
|
| Пустые | оба массива пусты → `[]` |
|
||||||
|
| Все совпадают | 1→1 |
|
||||||
|
| Ни один | 2→0 |
|
||||||
|
| Дубликаты | Set корректно обрабатывает повторы |
|
||||||
|
| Иммутабельность | исходный массив не мутирован |
|
||||||
|
|
||||||
|
### renderGroupCard (10 тестов)
|
||||||
|
Чистая HTML-функция: карточка необработанной группы.
|
||||||
|
|
||||||
|
| Группа | Тесты |
|
||||||
|
|--------|-------|
|
||||||
|
| Базовая | номер, контрагент, типы документов, дата, data-gi, кнопка «Сравнить» |
|
||||||
|
| Нет «✓ Готово» | для необработанной группы |
|
||||||
|
| `compare.status === 'running'` | кнопка disabled |
|
||||||
|
| Без контрагента | заглушка «контрагент не определён» |
|
||||||
|
| Неизвестный doc_type | выводится как есть |
|
||||||
|
| Пустые документы | карточка рендерится, кнопка есть |
|
||||||
|
| Типы | contract→договор, supplement→допсоглашение, specification→спецификация |
|
||||||
|
|
||||||
|
### renderGroupCardDone (5 тестов)
|
||||||
|
Чистая HTML-функция: карточка обработанной группы.
|
||||||
|
|
||||||
|
| Группа | Тесты |
|
||||||
|
|--------|-------|
|
||||||
|
| Заголовок | ✓ Готово (12.5с), стрелка сворачивания |
|
||||||
|
| bodyHTML | вставлен в карточку |
|
||||||
|
| data-gi | на заголовке для обработчика клика |
|
||||||
|
| Типы | specification→спецификация |
|
||||||
|
|
||||||
|
### renderUnresolvedCard (3 теста)
|
||||||
|
Чистая HTML-функция: карточка нераспознанных файлов.
|
||||||
|
|
||||||
|
| Группа | Тесты |
|
||||||
|
|--------|-------|
|
||||||
|
| Заголовок | «Не распознано» |
|
||||||
|
| Причина 1 | «нет базового договора №999» (есть parent_number) |
|
||||||
|
| Причина 2 | «ошибка классификации: LLM error» (classify_status=failed) |
|
||||||
|
|
||||||
|
### applyCompareEvent (14 тестов)
|
||||||
|
Чистая функция: мутирует `sections` по SSE-событиям.
|
||||||
|
|
||||||
|
| Группа | Тесты |
|
||||||
|
|--------|-------|
|
||||||
|
| `extract_start` | создаёт секцию с filename, status='extracting' |
|
||||||
|
| `llm_done` | обновляет status, ops_count, mode, time_s |
|
||||||
|
| `applied` | сохраняет summary, ops |
|
||||||
|
| `extract_error` | на существующей секции → status='error' |
|
||||||
|
| `extract_error` | на НЕсуществующей → создаёт новую с ошибкой |
|
||||||
|
| `apply_error` | аналогично extract_error |
|
||||||
|
| `applied` без summary | не падает |
|
||||||
|
| `applied` с пустыми ops | не падает |
|
||||||
|
| Неизвестный тип | секция НЕ создаётся (не падает) |
|
||||||
|
| `llm_done` на несуществующей | не падает |
|
||||||
|
|
||||||
|
### renderCompareSectionHeader (5 тестов)
|
||||||
|
Чистая функция: заголовок секции сравнения.
|
||||||
|
|
||||||
|
| Статус | Проверка |
|
||||||
|
|--------|----------|
|
||||||
|
| `extracting` | ⏳ + filename |
|
||||||
|
| `llm_done` | ✓ + filename + ops_count + mode + time_s |
|
||||||
|
| `error` | ✗ + filename + текст ошибки |
|
||||||
|
|
||||||
|
### renderCompareOpsTable (6 тестов)
|
||||||
|
Чистая функция: HTML-таблица операций.
|
||||||
|
|
||||||
|
| Группа | Тесты |
|
||||||
|
|--------|-------|
|
||||||
|
| ADD/DELETE | правильные CSS-классы (diff-added, diff-deleted) |
|
||||||
|
| Данные | имя услуги, цена, количество, сумма, дата |
|
||||||
|
| Пустые ops | таблица рендерится с пустым tbody |
|
||||||
|
| null new_row | пустые ячейки (не падает) |
|
||||||
|
| MERGE (неизв.) | без классов diff-* |
|
||||||
|
|
||||||
|
### renderCompareSectionBody (7 тестов)
|
||||||
|
Чистая функция: тело секции (summary + таблица).
|
||||||
|
|
||||||
|
| Группа | Тесты |
|
||||||
|
|--------|-------|
|
||||||
|
| Граничные | null, {}, status='llm_done' → пусто |
|
||||||
|
| applied | +3 ~1 -0 ?2, UPDATE → diff-changed |
|
||||||
|
| Без unresolved | нет знака ? |
|
||||||
|
| Отрицательные summary | рендерится без ошибок |
|
||||||
|
| Частичный new_row | нет undefined в выводе |
|
||||||
|
|
||||||
|
### escHtml (7 тестов)
|
||||||
|
| Вход | Выход |
|
||||||
|
|------|-------|
|
||||||
|
| `null`, `undefined` | `''` |
|
||||||
|
| `'hello'` | `'hello'` |
|
||||||
|
| `'<script>'` | `'<script>'` |
|
||||||
|
| `'a&b'` | `'a&b'` |
|
||||||
|
| `'"quote"'` | `'"quote"'` |
|
||||||
|
| `123` | `'123'` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Smoke Tests (curl)
|
||||||
|
|
||||||
|
| Эндпоинт | Код |
|
||||||
|
|----------|-----|
|
||||||
|
| `GET /` | 200 |
|
||||||
|
| `POST /api/cleanup` | 200 |
|
||||||
|
| `GET /static/state.js` | 200 |
|
||||||
|
| `GET /static/app_utils.js` | 200 |
|
||||||
|
| `GET /static/files.js` | 200 |
|
||||||
|
| `GET /static/groups.js` | 200 |
|
||||||
|
| `GET /static/compare.js` | 200 |
|
||||||
|
| `GET /static/app.js` | 200 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Баги, найденные и исправленные при аудите
|
||||||
|
|
||||||
|
| # | Баг | Файл | Исправление |
|
||||||
|
|---|-----|------|-------------|
|
||||||
|
| 1 | `SITE_URL` — неиспользуемая переменная | app.js:8 | Удалена |
|
||||||
|
| 2 | `UNZIP_URL` — двойное объявление | app.js:6-7 | Удалён дубль |
|
||||||
|
| 3 | `promptEditor` — двойное объявление | app.js:467,470 | Удалён дубль |
|
||||||
|
| 4 | `llmStart` — неиспользуемая после Фазы 3 | app.js:301 | Удалена |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура модулей (финальная)
|
||||||
|
|
||||||
|
```
|
||||||
|
contractor/deploy/
|
||||||
|
├── state.js (70 строк) Центральное состояние
|
||||||
|
├── app_utils.js (120 строк) escHtml, formatSize, removeFile, ...
|
||||||
|
├── files.js (480 строк) upload, parse, ZIP, renderFiles, syncDB
|
||||||
|
├── groups.js (230 строк) loadGroupsAction, renderGroups, renderGroupCard
|
||||||
|
├── compare.js (230 строк) startCompareSSE, applyCompareEvent, renderCompareOpsTable
|
||||||
|
├── app.js (450 строк) Оркестратор: render, classify, compare, prompts, chat
|
||||||
|
└── tests.js (400 строк) 131 юнит-тест
|
||||||
|
```
|
||||||
|
|
||||||
|
Загрузка в `index.cfm`: state → app_utils → files → groups → compare → app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Команды для регресса
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Синтаксис всех модулей
|
||||||
|
cd contractor/deploy && for f in state.js app_utils.js files.js groups.js compare.js app.js; do node -c "$f" || echo "FAIL: $f"; done
|
||||||
|
|
||||||
|
# Юнит-тесты
|
||||||
|
node tests.js
|
||||||
|
|
||||||
|
# Деплой JS на ВМ
|
||||||
|
scp -i ~/.ssh/naeel_vm_id_ed25519 *.js naeel@5.172.178.213:/home/naeel/contracts/
|
||||||
|
|
||||||
|
# Дым API
|
||||||
|
curl -s --max-time 5 -o /dev/null -w "%{http_code}\n" https://contracts.kube5s.ru/static/state.js
|
||||||
|
```
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Ответы заказчика — опросник «Сверка договоров»
|
||||||
|
|
||||||
|
Дата: 26.06.2026 | Сергей Мищук ↔ Владимир Крупский
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Объём
|
||||||
|
|
||||||
|
**Вопрос:** Сколько файлов обычно загружаете за один раз?
|
||||||
|
- A. до 10–20
|
||||||
|
- B. 50–100
|
||||||
|
- C. 100+ (сотни/тысячи)
|
||||||
|
|
||||||
|
**Ответ:** ✅ **C. 100+ (сотни/тысячи)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Названия НУБЕС в договорах
|
||||||
|
|
||||||
|
**Вопрос:** Под какими названиями НУБЕС встречается в документах?
|
||||||
|
|
||||||
|
**Ответ:** Есть официальное название. Во всех интересующих нас договорах — Исполнитель.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. «Мусорные» документы
|
||||||
|
|
||||||
|
**Вопрос:** Какие можно игнорировать при сверке?
|
||||||
|
|
||||||
|
**Ответ:** Все, кроме перечисленных (спецификация и дополнительное соглашение, возможно договор).
|
||||||
|
|
||||||
|
Т.е. игнорировать: акты сверки, счета/счета-фактуры/УПД, акты оказанных услуг, платёжные поручения. Оставлять: договоры, доп. соглашения, спецификации.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ZIP-архивы
|
||||||
|
|
||||||
|
**Вопрос:** Обычно один архив = один контрагент?
|
||||||
|
|
||||||
|
**Ответ:** Чаще всего 1 архив = 1 контрагент, но гарантировать не могу.
|
||||||
|
|
||||||
|
Размеры ZIP-файлов: не громадные.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Что важнее всего в результате
|
||||||
|
|
||||||
|
**Ответ:** Разобранный результат. Окончательная задача — **построчное сравнение данных из CRM с данными из фискальной системы**. Разница может быть:
|
||||||
|
- в количестве или ценах (ошибки ввода, ошибки процесса)
|
||||||
|
- особенно в **разных датах начала оказания услуг**
|
||||||
|
- услуги PAYG (суффикс `-m`), но кодов артикулов, вероятно, нет в счетах
|
||||||
|
|
||||||
|
**Сверку с CRM тоже можно делегировать AI.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Уточняющие вопросы (после опросника)
|
||||||
|
|
||||||
|
| Время | Вопрос (Крупский) | Ответ (Мищук) |
|
||||||
|
|---|---|---|
|
||||||
|
| 15:34 | Размеры документов не громадные? ZIP-файлов вернее. | Нет. |
|
||||||
|
| 15:36 | Это за месяц, квартал? | Могут быть разные потребности. Сверить по контрагенту, сверить за год, за месяц. |
|
||||||
|
| 15:37 | ZIP'ы — ввод из локали или например S3? | Не знаю. Спокойнее из локали, но может быть долго. |
|
||||||
|
| 15:37 | Т.е. по ссылке. | *(Крупский)* Пусть пока из локали, но сделаю с возможностью расширения. |
|
||||||
|
| 15:39 | — | Можно из файловой шары или облачного диска. Как работать с файлами — часть проекта, а не ТЗ. В ТЗ ограничение на строгую конфиденциальность → не хочется класть на публичный S3, мало ли кто ошибётся. |
|
||||||
|
| 15:40 | Модуль ввода сделаю типа API. | — |
|
||||||
|
| 15:41 | — | **Нас пока интересует не удобство загрузки, а точность анализа.** Скорее всего будем выявлять точечные ошибки и всё равно проверять глазами. Но пока не знаем масштаба расхождений и точности AI. |
|
||||||
|
| 15:42 | Ну да... трудно прогнозировать. Поэтому хардкодить логику не надо. | — |
|
||||||
|
| 15:42 | — | Удобство интерфейса пока — только для отладки. Сейчас оснастка выглядит полезной. |
|
||||||
|
| 15:43 | — | **Надо сначала отработать базовую функцию — разбор, сверка.** Если подход и точность устраивают — можно допилить для юзабельности. |
|
||||||
|
| 15:43 | — | Сейчас и с подходом всё неясно: что в контекст, что в RAG, что в базу, какая архитектура агентов... можно по-разному. |
|
||||||
|
| 15:44 | Посмотрю ещё по best practices. | — |
|
||||||
|
| 15:44 | — | Если интервью всё — пожелаю успехов и пойду к другим задачам? |
|
||||||
|
| 15:45 | ОК 😊 | — |
|
||||||
|
| 15:45 | — | Да, я бы хотел **картинок с вариантами архитектуры**. Видел, как AI сама такие рисует. |
|
||||||
|
| 15:45 | Дам задачу. | — |
|
||||||
|
| 15:46 | Для себя делал, но некрасиво и коротко. | — |
|
||||||
|
| 15:49 | — | Надо сначала посмотреть, может без красот обойдёмся. Это всё ради понимания. |
|
||||||
|
| 16:42 | Если файлов много — может их не надо все списком в таблице выводить? Показывать только статистику — количество по типам, размеры, что невозможно распарсить и т.д. | — |
|
||||||
|
| 17:11 | — | Зависит от того, что будем делать в интерфейсе. Например, посмотреть чего напарсили — нужен список. Если пока без этого — можно без списка. Но со списком будет удобнее отлаживать. |
|
||||||
|
| 17:12 | Либо по умолчанию список свёрнут. | — |
|
||||||
|
| 17:13 | Но это детали, там скроллинг. | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ключевые выводы
|
||||||
|
|
||||||
|
1. **Объём: 100+ файлов** → нужен пакетный режим, не поштучная загрузка.
|
||||||
|
2. **НУБЕС = Исполнитель** во всех целевых договорах.
|
||||||
|
3. **Оставлять только договоры/ДС/спецификации**, остальное игнорировать.
|
||||||
|
4. **1 архив ≈ 1 контрагент**, но не гарантировано.
|
||||||
|
5. **Главное — точность разбора**, а не удобство интерфейса. Сначала базовая функция, потом юзабельность.
|
||||||
|
6. **Конечная цель — сравнение CRM ↔ фискальная система**, с фокусом на даты начала услуг.
|
||||||
|
7. **PAYG** (суффикс `-m`) — особый случай.
|
||||||
|
8. **Сверку с CRM тоже можно делегировать AI.**
|
||||||
|
9. **Загрузка из локали** (файловая шара / облачный диск), не S3 (конфиденциальность).
|
||||||
|
10. **Архитектура неясна** — нужно исследовать подходы (контекст, RAG, база, агенты).
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# MVP Auto-classification — план реализации
|
||||||
|
|
||||||
|
## Итоговый план (Опус + мои правки)
|
||||||
|
|
||||||
|
### Фаза 1: БД + данные
|
||||||
|
1. ALTER documents — 7 колонок (doc_type, own_number, parent_number, doc_date TEXT, counterparty, classify_status, batch_id)
|
||||||
|
2. db/documents.py — set_classification(), list_pending(batch), list_by_batch(batch)
|
||||||
|
3. db/prompts.py — seed classify prompt
|
||||||
|
|
||||||
|
### Фаза 2: Классификация
|
||||||
|
4. llm_prompt.py — build_classify_prompt (строгий JSON)
|
||||||
|
5. services/classify.py — умная выжимка + LLM + ThreadPoolExecutor(4)
|
||||||
|
|
||||||
|
### Фаза 3: Группировка
|
||||||
|
6. services/grouping.py — normalize_number + group_documents + apply_groups
|
||||||
|
|
||||||
|
### Фаза 4: Эндпоинты
|
||||||
|
7. convert_server.py — POST /classify-batch, GET /api/groups, POST /apply-groups
|
||||||
|
|
||||||
|
### Фаза 5: UI
|
||||||
|
8. app.js — batch_id, classify button, polling progress, group cards, per-group compare
|
||||||
|
9. index.cfm — version bump only (HTML не меняем)
|
||||||
|
|
||||||
|
## Мои корректировки к плану Опуса
|
||||||
|
- batch_id генерируется на клиенте (crypto.randomUUID()), передаётся в upload
|
||||||
|
- ZIP — фаза 2, сначала multi-upload (уже работает)
|
||||||
|
- doc_date TEXT (не DATE) — LLM нормализует в ISO, при провале null
|
||||||
|
- parent_number отдельно от own_number
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# Инструкция: перенос UI и загрузки файлов с ВМ на Flask (Штурвал)
|
||||||
|
|
||||||
|
**Дата:** 2026-07-14
|
||||||
|
**Основано на:** drhider v0.0.26 — проверено в бою
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура загрузки (drhider как образец)
|
||||||
|
|
||||||
|
```
|
||||||
|
Браузер (index.html)
|
||||||
|
│ JS: uploadFiles() — цикл по файлам
|
||||||
|
│ XHR POST /api/upload (FormData, по одному файлу)
|
||||||
|
▼
|
||||||
|
Flask (api_bp.py)
|
||||||
|
│ upload(): сохраняет в сессию → {session_id}
|
||||||
|
│ process_stream(): SSE — обработка с прогрессом
|
||||||
|
▼
|
||||||
|
Python (drhider/*.py)
|
||||||
|
│ obfuscate_files() — основная логика
|
||||||
|
▼
|
||||||
|
Flask → ZIP + CSV → браузеру
|
||||||
|
```
|
||||||
|
|
||||||
|
## Файлы, отвечающие за UI и загрузку
|
||||||
|
|
||||||
|
### 1. `site/templates/index.html` — весь фронтенд
|
||||||
|
|
||||||
|
**Ключевые элементы:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Файловый инпут — множественный выбор -->
|
||||||
|
<input type="file" id="fileInput" multiple accept=".docx,.pdf,.txt,.zip">
|
||||||
|
|
||||||
|
<!-- Таблица выбранных файлов -->
|
||||||
|
<table>
|
||||||
|
<tbody id="fileList"></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Статус-бар -->
|
||||||
|
<div class="status" id="status"></div>
|
||||||
|
|
||||||
|
<!-- Кнопки скачивания (скрыты до готовности) -->
|
||||||
|
<div class="dl-btns" id="dlBtns">
|
||||||
|
<button onclick="downloadZip()">📦 Скачать ZIP</button>
|
||||||
|
<button onclick="downloadCsv()">📋 Скачать CSV</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ключевые JS-функции:**
|
||||||
|
|
||||||
|
| Функция | Что делает |
|
||||||
|
|---------|------------|
|
||||||
|
| `resetAll()` | Сброс всего состояния (F5, между загрузками) |
|
||||||
|
| `rr()` | Перерисовка таблицы файлов |
|
||||||
|
| `uploadFiles()` | **Главная** — цикл загрузки + SSE-обработка |
|
||||||
|
| `ss(idx, html)` | Обновление ячейки статуса в таблице |
|
||||||
|
| `downloadZip()` / `downloadCsv()` | Скачивание результатов |
|
||||||
|
|
||||||
|
**Критичные исправления (v0.0.26):**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 1. Прогрев upstream при загрузке страницы — обязательно!
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
sf = []; fi.value = ''; rr();
|
||||||
|
fetch('/health').catch(() => {}); // ← вот это
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Abort предыдущих запросов перед новым
|
||||||
|
let activeES = null; // активный EventSource
|
||||||
|
let activeXHR = null; // активный XHR
|
||||||
|
|
||||||
|
// 3. Защита от F5 во время загрузки
|
||||||
|
window.addEventListener('beforeunload', () => resetAll());
|
||||||
|
|
||||||
|
// 4. Сохранение списка файлов до очистки
|
||||||
|
const files = sf.slice(); // копия перед resetAll()
|
||||||
|
|
||||||
|
// 5. Цикл загрузки (по одному файлу)
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('files', f, f.name);
|
||||||
|
if (currentSid) fd.append('session', currentSid);
|
||||||
|
const resp = await fetch('/api/upload?_=' + Date.now(), {
|
||||||
|
method: 'POST', body: fd
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
currentSid = data.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. SSE-обработка
|
||||||
|
activeES = new EventSource('/api/process_stream/' + currentSid);
|
||||||
|
activeES.addEventListener('start', ...); // файл начат
|
||||||
|
activeES.addEventListener('done', ...); // файл готов
|
||||||
|
activeES.addEventListener('complete', ...); // всё готово
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `site/routes/api_bp.py` — API эндпоинты
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/upload — загрузка одного файла → {session_id}
|
||||||
|
GET /api/process_stream/<sid> — SSE: обработка с прогрессом
|
||||||
|
POST /api/process/<sid> — обработка без SSE (legacy)
|
||||||
|
GET /api/download/<sid> — скачать ZIP
|
||||||
|
GET /api/csv/<sid> — скачать CSV отдельно
|
||||||
|
```
|
||||||
|
|
||||||
|
**Критичные заголовки для SSE:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
return Response(
|
||||||
|
stream_with_context(generate()),
|
||||||
|
content_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no" # ← без этого nginx буферизует SSE
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Критично: GeneratorExit в SSE-генераторе:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def generate():
|
||||||
|
for idx, (fname, content) in enumerate(files):
|
||||||
|
try:
|
||||||
|
yield f"event: start\ndata: ...\n\n"
|
||||||
|
except GeneratorExit:
|
||||||
|
return # клиент отключился — не обрабатываем дальше
|
||||||
|
|
||||||
|
# ... обработка файла ...
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield f"event: done\ndata: ...\n\n"
|
||||||
|
except GeneratorExit:
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `site/routes/health_bp.py` — liveness probe
|
||||||
|
|
||||||
|
```python
|
||||||
|
@health_bp.route("/health")
|
||||||
|
def health():
|
||||||
|
return jsonify({"ok": True, "version": "0.0.1"})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Обязательно для Штурвала.**
|
||||||
|
|
||||||
|
### 4. `site/app.py` — точка входа
|
||||||
|
|
||||||
|
**Критичные настройки:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
VERSION = "0.0.1"
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["VERSION"] = VERSION
|
||||||
|
app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200 MB
|
||||||
|
|
||||||
|
from routes import register_routes
|
||||||
|
register_routes(app)
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def no_cache(response):
|
||||||
|
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
response.headers["Expires"] = "0"
|
||||||
|
return response
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пошаговая инструкция миграции
|
||||||
|
|
||||||
|
### Шаг 1: Скопировать скелет UI
|
||||||
|
|
||||||
|
Скопировать `site/templates/index.html` из drhider как основу.
|
||||||
|
Заменить:
|
||||||
|
- Заголовок (`<title>`, `.title`, `.card-header`)
|
||||||
|
- Текст описания
|
||||||
|
- `accept` в `<input type="file">` если другие форматы
|
||||||
|
- Логику в `uploadFiles()` — вызов своего API вместо `obfuscate_files`
|
||||||
|
|
||||||
|
### Шаг 2: Адаптировать API
|
||||||
|
|
||||||
|
Скопировать `site/routes/api_bp.py`, заменить:
|
||||||
|
- Название blueprint'а
|
||||||
|
- Логику в `process_stream()` — вызов своей функции вместо `obfuscate_files()`
|
||||||
|
- Формат SSE-событий если нужен другой
|
||||||
|
|
||||||
|
### Шаг 3: Health endpoint
|
||||||
|
|
||||||
|
Скопировать `site/routes/health_bp.py` как есть. Только версию поменять.
|
||||||
|
|
||||||
|
### Шаг 4: app.py
|
||||||
|
|
||||||
|
Скопировать `site/app.py`, заменить:
|
||||||
|
- `VERSION`
|
||||||
|
- `MAX_CONTENT_LENGTH` если нужен другой лимит
|
||||||
|
- Импорт своих blueprint'ов
|
||||||
|
|
||||||
|
### Шаг 5: session.py
|
||||||
|
|
||||||
|
Скопировать `site/session.py` как есть. Это in-memory хранилище загруженных файлов и результатов.
|
||||||
|
|
||||||
|
### Шаг 6: Интеграция бизнес-логики
|
||||||
|
|
||||||
|
Твоя функция обработки должна принимать тот же интерфейс что и `obfuscate_files`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def твоя_функция(files, **kwargs):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
files: list of (filename: str, content: bytes, mimetype: str)
|
||||||
|
Returns:
|
||||||
|
zip_bytes: bytes — ZIP-архив с результатами
|
||||||
|
csv_str: str — CSV с маппингом (или "")
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чек-лист перед деплоем
|
||||||
|
|
||||||
|
- [ ] `/health` возвращает `{"ok": true}`
|
||||||
|
- [ ] `MAX_CONTENT_LENGTH` достаточен для файлов
|
||||||
|
- [ ] `no_cache` after_request есть
|
||||||
|
- [ ] `X-Accel-Buffering: no` на SSE-эндпоинте
|
||||||
|
- [ ] `GeneratorExit` в каждом `yield` SSE-генератора
|
||||||
|
- [ ] `stream_with_context` оборачивает генератор
|
||||||
|
- [ ] `fetch('/health')` при загрузке страницы
|
||||||
|
- [ ] `beforeunload` → `resetAll()`
|
||||||
|
- [ ] `activeXHR` и `activeES` очищаются перед новым запуском
|
||||||
|
- [ ] `Connection: close` — **НЕ ставить** (Waitress/PEP 3333 запрещает hop-by-hop)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ссылки на файлы-образцы
|
||||||
|
|
||||||
|
| Что | Где в drhider |
|
||||||
|
|-----|---------------|
|
||||||
|
| HTML/JS/CSS фронтенд | `site/templates/index.html` |
|
||||||
|
| API (upload + SSE + download) | `site/routes/api_bp.py` |
|
||||||
|
| Health probe | `site/routes/health_bp.py` |
|
||||||
|
| Точка входа Flask | `site/app.py` |
|
||||||
|
| In-memory сессии | `site/session.py` |
|
||||||
|
| Полная архитектура | `docs/ARCHITECTURE.md` |
|
||||||
|
| Решённые проблемы кластера | `PROBLEM-AND-SOLUTION.md` |
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# VM — contracts.kube5s.ru (5.172.178.213)
|
||||||
|
|
||||||
|
## SSH
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213
|
||||||
|
```
|
||||||
|
|
||||||
|
## Сервисы
|
||||||
|
|
||||||
|
| Сервис | Порт | systemd unit | Назначение |
|
||||||
|
|---|---|---|---|
|
||||||
|
| gunicorn (Flask UI) | 5001 | — (start.sh) | Веб-интерфейс |
|
||||||
|
| convert_server.py | 8766 | `contracts` | API: upload, classify, compare, /static |
|
||||||
|
| drhider_server.py | 8767 | `contracts-drhider` | Обфускация документов |
|
||||||
|
| nginx | 443 | `nginx` | Прокси + SSL (Certbot) |
|
||||||
|
| PostgreSQL | 5432 | — | БД `baza`, пользователь `super` |
|
||||||
|
| act_runner | — | `act_runner` | Gitea CI runner |
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Из папки contracts-flask/:
|
||||||
|
bash deploy/sync.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт копирует `deploy/compare/`, `deploy/drhider/`, `deploy/db/`, `deploy/convert_*.py` → `/home/naeel/contracts/` на ВМ, затем делает `sudo systemctl restart contracts contracts-drhider`.
|
||||||
|
|
||||||
|
Nginx-конфиг — вручную:
|
||||||
|
```bash
|
||||||
|
scp -i ~/.ssh/naeel_vm_id_ed25519 deploy/nginx-contracts.conf naeel@5.172.178.213:/tmp/
|
||||||
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 sudo cp /tmp/nginx-contracts.conf /etc/nginx/sites-enabled/
|
||||||
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 'sudo nginx -t && sudo systemctl reload nginx'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Команды на ВМ
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Статус сервисов
|
||||||
|
systemctl status contracts contracts-drhider
|
||||||
|
|
||||||
|
# Логи
|
||||||
|
journalctl -u contracts -f
|
||||||
|
journalctl -u contracts-drhider -f
|
||||||
|
|
||||||
|
# Перезапуск
|
||||||
|
sudo systemctl restart contracts
|
||||||
|
sudo systemctl restart contracts-drhider
|
||||||
|
|
||||||
|
# Nginx
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
|
||||||
|
# БД
|
||||||
|
psql -U super -d baza
|
||||||
|
|
||||||
|
# Порты
|
||||||
|
ss -tlnp | grep -E '5001|8766|8767|5432'
|
||||||
|
```
|
||||||
|
|
||||||
|
## .env
|
||||||
|
|
||||||
|
Файл на ВМ: `/home/naeel/contracts/.env`
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=baza
|
||||||
|
DB_USER=super
|
||||||
|
DB_PASS=
|
||||||
|
LLM_API_URL=https://api.aillm.ru/v1/chat/completions
|
||||||
|
LLM_API_KEY=...
|
||||||
|
LLM_MODEL=gpt-oss-120b
|
||||||
|
```
|
||||||
|
|
||||||
|
Flask (`site/app.py`) читает `VM_API` (по умолчанию `https://contracts.kube5s.ru`).
|
||||||
|
|
||||||
|
## Nginx-роутинг
|
||||||
|
|
||||||
|
```
|
||||||
|
contracts.kube5s.ru :443
|
||||||
|
/ → 127.0.0.1:5001 Flask UI
|
||||||
|
/api/drhider → 127.0.0.1:8767 DrHider обфускация
|
||||||
|
/api/* → 127.0.0.1:8766 convert_server
|
||||||
|
/static/ → 127.0.0.1:8766
|
||||||
|
/upload → 127.0.0.1:8766
|
||||||
|
/lucee/* → contractor.luceek8s.dev.nubes.ru (legacy)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура БД (PostgreSQL `baza`)
|
||||||
|
|
||||||
|
| Таблица | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `documents` | Загруженные документы |
|
||||||
|
| `contracts` | Договоры |
|
||||||
|
| `supplements` | Допсоглашения к договорам |
|
||||||
|
| `spec_current` | Текущая спецификация услуг |
|
||||||
|
| `spec_events` | История изменений спецификации |
|
||||||
|
| `prompts` | LLM-промпты (extract/diff) |
|
||||||
|
| `upload_chunks` | Чанки загрузки |
|
||||||
|
|
||||||
|
## Файлы на ВМ
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/naeel/contracts/
|
||||||
|
├── .env
|
||||||
|
├── convert_server.py # API-сервер :8766
|
||||||
|
├── convert_doc.py # Конвертер .doc → .docx
|
||||||
|
├── drhider/
|
||||||
|
│ ├── drhider.py # Ядро обфускации
|
||||||
|
│ └── drhider_server.py # HTTP-сервер :8767
|
||||||
|
├── compare/
|
||||||
|
│ ├── process.py, llm.py, classify.py, ...
|
||||||
|
│ └── parse.py, grouping.py, upload.py
|
||||||
|
├── db/
|
||||||
|
│ ├── connection.py # Пул коннектов
|
||||||
|
│ ├── documents.py, contracts.py, supplements.py
|
||||||
|
│ └── spec_current.py, spec_events.py, prompts.py
|
||||||
|
├── files/ # Загруженные документы
|
||||||
|
└── llm_prompt.py # Шаблоны промптов extract/diff
|
||||||
|
```
|
||||||
|
|
||||||
|
## Пути в проекте
|
||||||
|
|
||||||
|
```
|
||||||
|
contracts-flask/
|
||||||
|
├── deploy/ → копируется на ВМ (sync.sh)
|
||||||
|
├── site/ → Flask UI (gunicorn на ВМ :5001)
|
||||||
|
├── Dockerfile → контейнеризация (не используется на ВМ)
|
||||||
|
└── .gitea/workflows/deploy.yaml → CI (act_runner)
|
||||||
|
```
|
||||||
+1
-1
Submodule contractor updated: 493d75e51a...c56b980501
Submodule
+1
Submodule contracts-flask added at 96718fc065
+93
@@ -0,0 +1,93 @@
|
|||||||
|
этот проект - имеет какую то коммерческую ценность ? как универсальный продукт для подобных документов
|
||||||
|
|
||||||
|
Список задач обновлен
|
||||||
|
|
||||||
|
Честная оценка, на основе того, что я видел в коде и архитектуре.
|
||||||
|
|
||||||
|
## Что это по сути
|
||||||
|
Движок **сверки спецификаций по цепочке «договор + допсоглашения»**: парсинг → извлечение строк (услуга/цена/кол-во/сумма/дата) → LLM-диф (ADD/UPDATE/DELETE) → event-sourced актуальное состояние с привязкой каждого изменения к документу-источнику. Плюс классификация документов и группировка. Это не «ещё один чат с PDF», а конкретная боль с понятной ценностью.
|
||||||
|
|
||||||
|
## Где реальная ценность
|
||||||
|
- **Боль настоящая и дорогая.** Ручная сверка договора с десятком допников в закупках/финансах/юридическом — медленно и с ошибками в деньгах. Автоматизация с аудит-следом — это то, за что платят.
|
||||||
|
- **Ядро обобщаемо.** Паттерн «базовый документ + поток изменений к таблице позиций» покрывает не только договоры: прайс-листы и их обновления, тарифы, страховые полисы + эндорсменты, аренда + доп.соглашения, SLA. Движок event-sourcing под это годится почти как есть.
|
||||||
|
- **Сильные стороны архитектуры под продукт уже заложены:** аудит изменений (`source_document_id`), `UNRESOLVED` как human-in-the-loop, арифметические проверки, генератор тест-кейсов (testgen/), симулятор (sim/). Это редко делают на ранней стадии — хороший знак зрелости.
|
||||||
|
|
||||||
|
## Что мешает быть «универсальным продуктом» прямо сейчас
|
||||||
|
- **Захардкожено под РФ-спецификацию услуг:** промпты, поля классификации (`own_number`/`parent_number`), понятие «спецификация». Для универсальности нужна **конфигурируемая схема документа + промпты под тип** (договор / прайс / полис), а не один зашитый сценарий.
|
||||||
|
- **Точность = главный риск.** Одна неверная сумма/строка → потеря доверия, в финансах это критично. Нужны: обязательный человеческий контроль спорных строк (основа есть), метрики качества на эталонном датасете (заготовка в testgen есть — надо довести до бенчмарка с цифрами «точность X%»).
|
||||||
|
- **Робастность и контроль затрат** — ровно то, что обсуждали (тихие провалы парсинга, дубли, лимиты, стоимость LLM на документ). Для внутреннего инструмента терпимо, для продукта — обязательно.
|
||||||
|
- **Интеграции:** экспорт в 1С/ERP/учётные системы. Без этого результат сверки «висит в вебе» и ценность ниже.
|
||||||
|
|
||||||
|
## Рынок и позиционирование
|
||||||
|
- Большие CLM/contract-analytics (Ironclad, Evisort, Luminance, Kira) — это про весь жизненный цикл и поиск рисков, а **узкую задачу «сверка позиций по допникам» они толком не закрывают**. Здесь есть незанятая ниша.
|
||||||
|
- На РФ-рынке (СБИС/Контур и т.п.) — локальная специфика и язык как преимущество.
|
||||||
|
- **Реалистичное позиционирование:** не «платформа», а **вертикальный SaaS / модуль** для закупок и финансов SMB/mid-market: «загрузи договор и допники — получи актуальную спецификацию с аудитом изменений». Либо как встраиваемый компонент в чужой CLM/документооборот.
|
||||||
|
|
||||||
|
## Вердикт
|
||||||
|
Да, коммерческая ценность правдоподобна — как **сфокусированный нишевый продукт**, а не платформа. Ядро обобщается на класс «базовый документ + изменения к таблице», но для «универсальности» нужны три вещи по порядку: **(1) конфигурируемые схемы/промпты под тип документа, (2) доказанная точность с метриками + обязательный ревью спорных строк, (3) интеграции/экспорт**. Архитектурно фундамент под это уже правильный — event sourcing с аудитом и заготовки под тестирование/симуляцию дают серьёзное преимущество перед «обёрткой над LLM».
|
||||||
|
|
||||||
|
Главный нерешённый вопрос продукта — **не технологии, а доверие к цифрам**: кто несёт ответственность за ошибку в сумме и как это показать клиенту. Кто решит это через прозрачный аудит + ревью — тот и продаёт.
|
||||||
|
|
||||||
|
может не именно универсальный, запустил и работает
|
||||||
|
а с возможностью перестройки ядра под запросы конкретного заказчика
|
||||||
|
|
||||||
|
Список задач обновлен
|
||||||
|
|
||||||
|
Хорошо — это другая модель: «настраиваемое ядро + кастомизация под заказчика». Проверю, насколько ядро уже готово к перенастройке (промпты в БД, версионирование).
|
||||||
|
|
||||||
|
Read [](file:///home/naeel/nubes/contracts/contracts-flask/deploy/db/prompts.py#1-1), lines 1 to 80
|
||||||
|
|
||||||
|
Подтвердилось важное: **промпты уже вынесены в БД и версионируются** (prompts.py, таблица `prompts` с ролями `extract`/`diff`, `is_active`, выбор активного), а `prompt_version` пишется в каждое событие. То есть значительная часть «перестройки ядра» уже делается **данными, а не кодом**. Это сильно меняет оценку в плюс.
|
||||||
|
|
||||||
|
## Почему эта модель — правильная (и лучше «универсальной»)
|
||||||
|
«Универсальный, запустил и работает» в B2B-документах почти не существует — у каждого заказчика свои формы, поля, правила. **«Конфигурируемое ядро + настройка под заказчика»** — это проверенная и доминирующая модель корпоративного софта (весь экосистема 1С построена ровно на ней). Заказчик такого ждёт и за это платит. Так что вы целитесь в реалистичную нишу, а не в утопию.
|
||||||
|
|
||||||
|
## Что уже играет на вас
|
||||||
|
- **Промпты = данные, с версиями.** Перенастроить извлечение/диф под клиента — это правка записи в `prompts`, не релиз. Огромный задел.
|
||||||
|
- **Воспроизводимость/аудит:** `prompt_version` и `raw_llm_response` пишутся в события → можно объяснить клиенту любое решение и откатить промпт. Это и тюнинг, и доверие.
|
||||||
|
- **Заготовка под качество:** testgen/ + метрики → можно собирать **эталонный датасет под каждого заказчика** и мерить точность его конфигурации.
|
||||||
|
- Event sourcing с привязкой к источнику — ядро не зависит от доменной специфики, оно про «база + поток изменений к таблице».
|
||||||
|
|
||||||
|
## Где сейчас «настройка = код» (это надо вынести в конфиг)
|
||||||
|
- **Схема позиции захардкожена:** поля `name/price/qty/sum/date_start` — в колонках `spec_current` и в spec_events.py. У другого заказчика будут иные поля (артикул, НДС, период, ед.изм.). Сейчас это правка кода/миграции.
|
||||||
|
- **Таксономия классификации** (`own_number`/`parent_number`/`counterparty`) — зашита.
|
||||||
|
- **Валидаторы** (арифметика `sum=price·qty`) и **экспорт** — под конкретный домен.
|
||||||
|
|
||||||
|
Пока это код — каждый новый заказчик тянет за собой разработку. Цель — сделать вариативные точки **конфигурацией («доменный пакет»: схема + промпты + правила + маппинг экспорта)**, чтобы новый клиент = настройка, а не форк.
|
||||||
|
|
||||||
|
## Экономика модели — честно
|
||||||
|
- **Плюсы:** выше средний чек, выше «прилипчивость» (настроенное ядро не выкинут), естественный апсейл на новые типы документов.
|
||||||
|
- **Минусы:** services-heavy — внедрение/настройка тянут маржу вниз и хуже масштабируются, чем чистый SaaS. В РФ-энтерпрайзе это норма (интеграторы на этом и живут).
|
||||||
|
- **Ключевая метрика бизнеса:** «время вывода нового заказчика». Если настройка — это **дни конфигурации**, экономика продуктовая. Если **месяцы разработки** — вы консалтинг с кодовой базой, а не продукт.
|
||||||
|
|
||||||
|
## Главный риск и как его держать
|
||||||
|
**Не плодить ветки под заказчиков.** Один core + набор «доменных пакетов»-конфигов. Как только появляется `if customer == X` в коде ядра — модель сломалась. Дисциплина: всё клиентское — в данные/конфиг, ядро остаётся общим. И второе — **качество промптов под клиента нужно мерить** (testgen как first-class инструмент онбординга), иначе «настройка» превратится в бесконечный ручной тюнинг.
|
||||||
|
|
||||||
|
## Вердикт
|
||||||
|
Да, **эта модель коммерчески прочнее «универсального продукта»** и реалистичнее. Вы уже на полпути: промпты вынесены и версионируются, аудит и заготовка под тесты есть. Чтобы превратить это в продаваемый «настраиваемый продукт», ключевая инженерная инвестиция одна — **сделать доменную часть (схема позиции, классификация, правила, экспорт) конфигурацией, а не кодом**. Сделаете — получите продукт с услугой внедрения; не сделаете — останетесь разработкой под каждого клиента.
|
||||||
|
|
||||||
|
Создано задач: 5
|
||||||
|
|
||||||
|
|
||||||
|
есть ли подобные фреймворки или системы
|
||||||
|
|
||||||
|
Список задач обновлен
|
||||||
|
|
||||||
|
Кратко, по категориям:
|
||||||
|
|
||||||
|
**Contract analytics / CLM (готовые продукты):**
|
||||||
|
- Evisort, Ironclad, ContractPodAi, Luminance, Kira Systems, DocuSign CLM, Icertis — извлечение полей и рисков из договоров. Но **сверку позиций по цепочке допсоглашений с event-sourcing они напрямую не делают** — это про метаданные и риски, не про «актуальная спецификация после N изменений».
|
||||||
|
|
||||||
|
**Document AI / IDP (извлечение таблиц/полей):**
|
||||||
|
- AWS Textract, Google Document AI, Azure Document Intelligence, ABBYY FlexiCapture/Vantage, Rossum, Docsumo — парсинг и извлечение структурированных данных. Это «слой парсинга», без вашей логики диффа/состояния.
|
||||||
|
|
||||||
|
**LLM-фреймворки «собери сам» (то, на чём строят такое):**
|
||||||
|
- LangChain / LlamaIndex, Unstructured.io (парсинг документов), Instructor / Outlines (structured JSON от LLM). Дают кирпичи, но не доменное ядро.
|
||||||
|
|
||||||
|
**Российский рынок:**
|
||||||
|
- Контур, СБИС, Directum, ЭОС — документооборот/ЭДО с элементами анализа. Локальная специфика, но узкой «сверки спецификаций» как продукта нет.
|
||||||
|
|
||||||
|
**Diff / редлайн:**
|
||||||
|
- Litera Compare, Draftable, Workshare — сравнение версий документов **текстуально**, не семантически по строкам спецификации.
|
||||||
|
|
||||||
|
**Вывод:** компонентов-аналогов много (парсинг, извлечение, CLM, structured-LLM), но **именно вашей связки** — «базовый документ + поток допников → event-sourced актуальная таблица позиций с аудитом» как готового продукта/фреймворка я не знаю. Это и есть незанятая ниша: вы не конкурируете с CLM, а закрываете то, что они оставляют ручным.
|
||||||
@@ -5,3 +5,6 @@ sk-ucI5YvOticoOQ9Kuj5K9mQ
|
|||||||
|
|
||||||
Deepseek flash
|
Deepseek flash
|
||||||
sk-78ec529c1eba4ba69995091046c9fa33
|
sk-78ec529c1eba4ba69995091046c9fa33
|
||||||
|
|
||||||
|
cicd
|
||||||
|
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOTllMzI1LTFjZGYtN2NkYS05MzE5LWU1MzAyYTg1ZTI5MSIsImV4cCI6MTc5ODI3MTUyMSwiaWF0IjoxNzgyNzE5NTIxLCJqdGkiOiI5NjQ2MDlmYy05ZGZiLTQ1YjMtYjk0NS1lNmE0NmUzMTA0MzQiLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiJXWjAxMzI1IiwiY29tcGFueV9pZCI6IjNlNjRhYWM2LWRjZmMtNDA4Mi04OGRjLWRhMTljODY1NTVhNSIsImNvbXBhbnlfbmFtZSI6ItCi0LXRgdGCIiwidG9rZW5fdHlwZSI6InRlY2giLCJpZHBfdXNyX3VpZCI6IjAxOTllMzI1LTFjZGYtN2NkYS05MzE5LWU1MzAyYTg1ZTI5MSIsImxvZ2luIjoidGF6ZXRAbmFyb2QucnUiLCJmaXJzdG5hbWUiOiLQndCw0LjQu9GMIiwibWlkZGxlbmFtZSI6ItCk0LDRgNC40YHQvtCy0LjRhyDQotC10YHRgtC-0LLQsNGPINGD0YfQtdGC0LrQsCIsImxhc3RuYW1lIjoi0KLQsNC30LXRgtC00LjQvdC-0LIiLCJncm91cHMiOm51bGwsInByZWZlcnJlZF91c2VybmFtZSI6IiIsImdpdmVuX25hbWUiOiIiLCJmYW1pbHlfbmFtZSI6IiIsImVtYWlsIjoidGF6ZXRAbmFyb2QucnUifQ.T2cSkKGlorUTr_ICpInwrZZ2Sqk_D-RHpibrj1VI-7Bg7CPvIKJ7n1QF9bJc9uqWH9cwQczrNsA8sROU3lnqUaa88hl_rMfP7UM_u8X_iG-_pKYD8tsckmmcos6keh2I9muSZ9Viy9LvLCZv3fY6nzMp2YT-KCQh-EDGZPgHSAToWs1uqiaKi99K-OcqckvaFNUsYbpLPVfnD_6UnDDKUmPjP4Ib24R4Z5qlmwUAxmgC6BfUcuqgk-2Mdj37ulWvdlBLd9ZoJv4jAvRffzclv2w-Qa8p3BEooC8wlZjTC3PU-ULR-Cd_N61Y31lkd953kkKE3_yGIQCbwCPYus1TiA
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
Binary file not shown.
Binary file not shown.
+163
@@ -0,0 +1,163 @@
|
|||||||
|
"""
|
||||||
|
actions.py — API-обёртки для симулятора.
|
||||||
|
|
||||||
|
Все запросы к contracts.kube5s.ru с таймаутами.
|
||||||
|
Эмулирует действия пользователя через UI (загрузка, classify, groups, compare).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests, json, time, sys, os, uuid
|
||||||
|
|
||||||
|
BASE = "https://contracts.kube5s.ru"
|
||||||
|
TIMEOUT = 120
|
||||||
|
|
||||||
|
# Убрать warnings
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
def _post(url, **kw):
|
||||||
|
return requests.post(url, timeout=TIMEOUT, verify=False, **kw)
|
||||||
|
|
||||||
|
def _get(url, **kw):
|
||||||
|
return requests.get(url, timeout=TIMEOUT, verify=False, **kw)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Действия
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
"""Состояние сессии: batch_id, contract_id, files."""
|
||||||
|
def __init__(self):
|
||||||
|
self.batch_id = str(uuid.uuid4())
|
||||||
|
self.contract_id = None
|
||||||
|
self.files = [] # {name, doc_id, status}
|
||||||
|
|
||||||
|
def upload_file(self, path):
|
||||||
|
"""Загрузить один файл. Возвращает ответ API."""
|
||||||
|
fname = os.path.basename(path)
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
data = {'batch_id': self.batch_id}
|
||||||
|
if self.contract_id:
|
||||||
|
data['contract_id'] = self.contract_id
|
||||||
|
r = _post(f"{BASE}/upload", files={'files': (fname, f)}, data=data)
|
||||||
|
result = r.json()
|
||||||
|
if result.get('ok'):
|
||||||
|
doc_id = result.get('doc_id')
|
||||||
|
parsed = result.get('parsed', {})
|
||||||
|
self.files.append({
|
||||||
|
'name': fname, 'doc_id': doc_id,
|
||||||
|
'status': parsed.get('status', 'uploaded'),
|
||||||
|
'element_count': parsed.get('element_count', 0)
|
||||||
|
})
|
||||||
|
if result.get('contract_id'):
|
||||||
|
self.contract_id = result['contract_id']
|
||||||
|
return result
|
||||||
|
|
||||||
|
def upload_files(self, paths, delay=0.2):
|
||||||
|
"""Загрузить несколько файлов последовательно."""
|
||||||
|
for p in paths:
|
||||||
|
r = self.upload_file(p)
|
||||||
|
status = r.get('parsed', {}).get('status', r.get('ok', '?'))
|
||||||
|
print(f" ↑ {os.path.basename(p)}: {status}")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
def classify(self):
|
||||||
|
"""Запустить классификацию, ждать завершения."""
|
||||||
|
r = _post(f"{BASE}/api/classify-batch", json={'batch_id': self.batch_id})
|
||||||
|
result = r.json()
|
||||||
|
if not result.get('ok'):
|
||||||
|
print(f" ✗ classify failed: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Поллить прогресс
|
||||||
|
total = result.get('total', 0)
|
||||||
|
print(f" ⏳ classify {total} файлов...")
|
||||||
|
for _ in range(300): # макс 10 минут (300 × 2с)
|
||||||
|
time.sleep(2)
|
||||||
|
pr = _get(f"{BASE}/api/batch-progress?batch={self.batch_id}").json()
|
||||||
|
done = pr.get('counts', {}).get('classified', 0) + pr.get('counts', {}).get('failed', 0)
|
||||||
|
if done >= total and total > 0:
|
||||||
|
print(f" ✓ classify done: {pr.get('counts')}")
|
||||||
|
return pr
|
||||||
|
return {"ok": False, "error": "timeout"}
|
||||||
|
|
||||||
|
def get_groups(self):
|
||||||
|
"""Получить группы после классификации."""
|
||||||
|
r = _get(f"{BASE}/api/groups?batch={self.batch_id}")
|
||||||
|
data = r.json()
|
||||||
|
groups = data.get('groups', [])
|
||||||
|
real = [g for g in groups if g.get('contract_number') != '__unresolved__']
|
||||||
|
unres = [g for g in groups if g.get('contract_number') == '__unresolved__']
|
||||||
|
print(f" 📋 группы: {len(real)} (+ {len(unres)} нераспознано)")
|
||||||
|
for g in real:
|
||||||
|
print(f" №{g.get('contract_number')} — {g.get('counterparty','?')} ({len(g.get('documents',[]))} док.)")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def apply_and_compare(self, group_index=0):
|
||||||
|
"""Применить группу и запустить сравнение через SSE."""
|
||||||
|
r = _get(f"{BASE}/api/groups?batch={self.batch_id}")
|
||||||
|
groups = r.json().get('groups', [])
|
||||||
|
real = [g for g in groups if g.get('contract_number') != '__unresolved__']
|
||||||
|
if not real:
|
||||||
|
print(" ✗ нет групп для сравнения")
|
||||||
|
return None
|
||||||
|
|
||||||
|
group = real[group_index]
|
||||||
|
print(f" ⚖ compare группы №{group.get('contract_number')}...")
|
||||||
|
|
||||||
|
# Применить группу
|
||||||
|
ar = _post(f"{BASE}/api/apply-groups", json={'batch_id': self.batch_id, 'groups': [group]})
|
||||||
|
ad = ar.json()
|
||||||
|
if not ad.get('ok') or not ad.get('contract_ids'):
|
||||||
|
print(f" ✗ apply failed: {ad}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
cid = ad['contract_ids'][0]
|
||||||
|
|
||||||
|
# SSE compare
|
||||||
|
import sseclient # pip install sseclient-py
|
||||||
|
url = f"{BASE}/process-v2?contract_id={cid}"
|
||||||
|
response = requests.get(url, stream=True, timeout=300, verify=False)
|
||||||
|
client = sseclient.SSEClient(response)
|
||||||
|
|
||||||
|
sections = {}
|
||||||
|
total_ops = 0
|
||||||
|
for event in client.events():
|
||||||
|
d = json.loads(event.data)
|
||||||
|
t = d.get('type')
|
||||||
|
|
||||||
|
if t == 'extract_start':
|
||||||
|
sections[d['supplement_id']] = d['filename']
|
||||||
|
elif t == 'llm_done':
|
||||||
|
print(f" ✓ {sections.get(d['supplement_id'],'?')}: {d.get('ops_count',0)} оп. {d.get('mode','?')} ({d.get('time_s',0)}с)")
|
||||||
|
elif t == 'applied':
|
||||||
|
ops = d.get('ops', [])
|
||||||
|
total_ops += len(ops)
|
||||||
|
s = d.get('summary', {})
|
||||||
|
print(f" 📊 +{s.get('added',0)} ~{s.get('updated',0)} -{s.get('deleted',0)}")
|
||||||
|
elif t == 'done':
|
||||||
|
print(f" ✓ compare done: {d.get('total_time_s',0)}с, всего {total_ops} оп.")
|
||||||
|
return d
|
||||||
|
elif t == 'error':
|
||||||
|
print(f" ✗ compare error: {d.get('message','?')}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Очистить БД после теста."""
|
||||||
|
try:
|
||||||
|
_post(f"{BASE}/api/cleanup")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def upload_files_from_dir(session, dir_path, pattern="*", delay=0.2):
|
||||||
|
"""Загрузить все файлы из папки."""
|
||||||
|
import glob
|
||||||
|
files = sorted(glob.glob(os.path.join(dir_path, pattern)))
|
||||||
|
files = [f for f in files if f.endswith(('.docx', '.doc', '.pdf', '.zip'))]
|
||||||
|
if not files:
|
||||||
|
print(f" (нет файлов в {dir_path})")
|
||||||
|
return
|
||||||
|
print(f" Загрузка {len(files)} файлов из {dir_path}...")
|
||||||
|
session.upload_files(files, delay)
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
run.py — Точка входа симулятора.
|
||||||
|
|
||||||
|
Запуск: python3 sim/run.py [сценарий]
|
||||||
|
без аргументов — все сценарии A–F
|
||||||
|
A — хэппи-путь
|
||||||
|
B — зигзаг
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from scenarios import run_all, Session, OUT
|
||||||
|
import scenarios
|
||||||
|
|
||||||
|
SCENARIOS = {
|
||||||
|
'A': scenarios.scenario_A_happy,
|
||||||
|
'B': scenarios.scenario_B_zigzag,
|
||||||
|
'C': scenarios.scenario_C_bulk,
|
||||||
|
'D': scenarios.scenario_D_stupid,
|
||||||
|
'E': scenarios.scenario_E_zips,
|
||||||
|
'F': scenarios.scenario_F_stress,
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
name = sys.argv[1].upper()
|
||||||
|
if name in SCENARIOS:
|
||||||
|
s = Session()
|
||||||
|
SCENARIOS[name](s)
|
||||||
|
s.cleanup()
|
||||||
|
else:
|
||||||
|
print(f"Неизвестный сценарий: {name}. Доступны: {', '.join(SCENARIOS)}")
|
||||||
|
else:
|
||||||
|
run_all()
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
scenarios.py — Сценарии симуляции (A–F по схеме Опуса).
|
||||||
|
|
||||||
|
Каждый сценарий — функция, принимающая Session и пути к файлам.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time, os, glob, random
|
||||||
|
from actions import Session, upload_files_from_dir
|
||||||
|
|
||||||
|
OUT = os.path.join(os.path.dirname(__file__), "..", "testgen", "out")
|
||||||
|
|
||||||
|
def scenario_A_happy(session):
|
||||||
|
"""Хэппи-путь: загрузить контракт → classify → группы → compare."""
|
||||||
|
print("\n=== A: Хэппи-путь ===")
|
||||||
|
valid = os.path.join(OUT, "valid")
|
||||||
|
files = sorted(glob.glob(os.path.join(valid, "*XXX001*")))[:4] # договор + 2 спеки + 1 допник
|
||||||
|
session.upload_files(files)
|
||||||
|
session.classify()
|
||||||
|
session.get_groups()
|
||||||
|
session.apply_and_compare(0)
|
||||||
|
|
||||||
|
def scenario_B_zigzag(session):
|
||||||
|
"""Зигзаг: загрузил → удалил → добавил → classify."""
|
||||||
|
print("\n=== B: Зигзаг ===")
|
||||||
|
valid = os.path.join(OUT, "valid")
|
||||||
|
all_f = sorted(glob.glob(os.path.join(valid, "*.docx")))
|
||||||
|
|
||||||
|
# Загрузить 3
|
||||||
|
session.upload_files(all_f[:3])
|
||||||
|
# Удалить 1 (через sync — отправляем keep_ids без одного)
|
||||||
|
# Эмулируем: sync с текущим списком без первого
|
||||||
|
keep = [f['doc_id'] for f in session.files[1:]]
|
||||||
|
import requests
|
||||||
|
requests.post("https://contracts.kube5s.ru/api/sync", json={'keep_ids': keep}, timeout=10, verify=False)
|
||||||
|
session.files = session.files[1:]
|
||||||
|
print(f" ✕ удалён 1 файл, осталось {len(session.files)}")
|
||||||
|
|
||||||
|
# Добавить ещё 2
|
||||||
|
session.upload_files(all_f[3:5])
|
||||||
|
session.classify()
|
||||||
|
session.get_groups()
|
||||||
|
|
||||||
|
def scenario_C_bulk(session):
|
||||||
|
"""Bulk: загрузить все валидные разом."""
|
||||||
|
print("\n=== C: Bulk (все валидные) ===")
|
||||||
|
valid = os.path.join(OUT, "valid")
|
||||||
|
all_f = sorted(glob.glob(os.path.join(valid, "*.docx")))
|
||||||
|
session.upload_files(all_f, delay=0.1)
|
||||||
|
session.classify()
|
||||||
|
session.get_groups()
|
||||||
|
|
||||||
|
def scenario_D_stupid(session):
|
||||||
|
"""Тупые действия: дубликаты, удалить всё, мусор."""
|
||||||
|
print("\n=== D: Тупые действия ===")
|
||||||
|
valid = os.path.join(OUT, "valid")
|
||||||
|
errors = os.path.join(OUT, "errors")
|
||||||
|
|
||||||
|
# Загрузить файл дважды (эмуляция — API не спросит confirm)
|
||||||
|
f0 = sorted(glob.glob(os.path.join(valid, "*.docx")))[0]
|
||||||
|
session.upload_file(f0)
|
||||||
|
session.upload_file(f0) # дубликат — API перезапишет
|
||||||
|
print(f" дубликат: {len(session.files)} файлов (должен быть 1)")
|
||||||
|
|
||||||
|
# Мусор
|
||||||
|
trash = sorted(glob.glob(os.path.join(errors, "trash_*.docx")))[:3]
|
||||||
|
session.upload_files(trash)
|
||||||
|
|
||||||
|
# Удалить всё (sync с пустым списком)
|
||||||
|
import requests
|
||||||
|
requests.post("https://contracts.kube5s.ru/api/sync", json={'keep_ids': []}, timeout=10, verify=False)
|
||||||
|
session.files = []
|
||||||
|
session.contract_id = None
|
||||||
|
print(f" удалено всё: {len(session.files)} файлов")
|
||||||
|
|
||||||
|
# Загрузить заново
|
||||||
|
session.upload_files(sorted(glob.glob(os.path.join(valid, "*XXX005*"))))
|
||||||
|
session.classify()
|
||||||
|
|
||||||
|
def scenario_E_zips(session):
|
||||||
|
"""ZIP: загрузить архивы."""
|
||||||
|
print("\n=== E: ZIP-архивы ===")
|
||||||
|
zips = os.path.join(OUT, "zips")
|
||||||
|
all_z = sorted(glob.glob(os.path.join(zips, "*.zip")))
|
||||||
|
session.upload_files(all_z)
|
||||||
|
|
||||||
|
def scenario_F_stress(session):
|
||||||
|
"""Стресс: загрузить ошибки + мусор + валидные вперемешку."""
|
||||||
|
print("\n=== F: Стресс-микс ===")
|
||||||
|
valid = os.path.join(OUT, "valid")
|
||||||
|
errors = os.path.join(OUT, "errors")
|
||||||
|
|
||||||
|
mixed = []
|
||||||
|
mixed += sorted(glob.glob(os.path.join(valid, "*.docx")))[:5]
|
||||||
|
mixed += sorted(glob.glob(os.path.join(errors, "trash_*.docx")))[:5]
|
||||||
|
mixed += sorted(glob.glob(os.path.join(errors, "corrupt_xml.docx")))
|
||||||
|
mixed += sorted(glob.glob(os.path.join(errors, "orphan_*.docx")))[:2]
|
||||||
|
random.shuffle(mixed)
|
||||||
|
session.upload_files(mixed, delay=0.05)
|
||||||
|
session.classify()
|
||||||
|
session.get_groups()
|
||||||
|
|
||||||
|
def run_all():
|
||||||
|
"""Прогнать все сценарии."""
|
||||||
|
print("=" * 60)
|
||||||
|
print("СИМУЛЯТОР — сценарии A–F")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
scenarios = [
|
||||||
|
("A", scenario_A_happy),
|
||||||
|
("B", scenario_B_zigzag),
|
||||||
|
("C", scenario_C_bulk),
|
||||||
|
("D", scenario_D_stupid),
|
||||||
|
("E", scenario_E_zips),
|
||||||
|
("F", scenario_F_stress),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, fn in scenarios:
|
||||||
|
s = Session()
|
||||||
|
try:
|
||||||
|
fn(s)
|
||||||
|
print(f" ✅ сценарий {name} OK")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ сценарий {name} FAIL: {e}")
|
||||||
|
finally:
|
||||||
|
s.cleanup()
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("СИМУЛЯЦИЯ ЗАВЕРШЕНА")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_all()
|
||||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user