docs: архитектурный анализ + History + gitignore (2026-06-27)
This commit is contained in:
+4
-1
@@ -1,3 +1,6 @@
|
|||||||
FILES
|
FILES
|
||||||
contracts-app/
|
contracts-app/
|
||||||
dogovora/
|
dogovora/
|
||||||
|
testgen/out/
|
||||||
|
testgen/out_100files/
|
||||||
|
contracts-flask/
|
||||||
@@ -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 на ВМ.
|
||||||
@@ -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,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,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,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,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,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, база, агенты).
|
||||||
+1
-1
Submodule contractor updated: 2a5f255c7b...d95c17cc18
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,647 @@
|
|||||||
|
# ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
|
||||||
|
"""
|
||||||
|
gen_opus.py — Генератор тестовых файлов по 30 кейсам Опуса.
|
||||||
|
|
||||||
|
Блок A: classify (11 кейсов, ~11 файлов)
|
||||||
|
Блок B: group (9 кейсов, ~19 файлов)
|
||||||
|
Блок C: compare (10 кейсов, ~20 файлов)
|
||||||
|
|
||||||
|
Все документы — полноценные: шапка, преамбула с реквизитами сторон,
|
||||||
|
2-4 раздела, таблицы услуг (6-10 строк), подписи.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, json, sys
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from docx import Document
|
||||||
|
from docx.shared import Pt
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
|
||||||
|
OUT = os.path.join(os.path.dirname(__file__), "opus_cases")
|
||||||
|
MANIFEST = {}
|
||||||
|
|
||||||
|
def ensure_dir(path):
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
|
||||||
|
def save(doc, folder, fname, info):
|
||||||
|
path = os.path.join(OUT, folder, fname)
|
||||||
|
ensure_dir(os.path.dirname(path))
|
||||||
|
doc.save(path)
|
||||||
|
MANIFEST[fname] = info
|
||||||
|
print(f" ✓ {folder}/{fname}")
|
||||||
|
|
||||||
|
def _add_title(doc, text):
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
run = p.add_run(text)
|
||||||
|
run.bold = True
|
||||||
|
run.font.size = Pt(14)
|
||||||
|
|
||||||
|
def _add_para(doc, text):
|
||||||
|
doc.add_paragraph(text)
|
||||||
|
|
||||||
|
def _add_table(doc, rows_data, headers=None):
|
||||||
|
if headers:
|
||||||
|
rows_data = [headers] + rows_data
|
||||||
|
table = doc.add_table(rows=len(rows_data), cols=len(rows_data[0]))
|
||||||
|
table.style = 'Table Grid'
|
||||||
|
for ri, row in enumerate(rows_data):
|
||||||
|
for ci, val in enumerate(row):
|
||||||
|
table.rows[ri].cells[ci].text = str(val)
|
||||||
|
|
||||||
|
def _add_signatures(doc):
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, "10. ПОДПИСИ СТОРОН")
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, " ИСПОЛНИТЕЛЬ ЗАКАЗЧИК")
|
||||||
|
_add_para(doc, " ____________________ ____________________")
|
||||||
|
_add_para(doc, " Генеральный директор Генеральный директор")
|
||||||
|
_add_para(doc, " ____________________ ____________________")
|
||||||
|
_add_para(doc, " М.П. М.П.")
|
||||||
|
|
||||||
|
def _preamble(doc, num, date, party1, party2):
|
||||||
|
"""Стандартная преамбула договора."""
|
||||||
|
_add_para(doc, f'ООО "НУБЕС", именуемое в дальнейшем "Исполнитель", в лице')
|
||||||
|
_add_para(doc, f'Генерального директора Степаненко Василия Сергеевича,')
|
||||||
|
_add_para(doc, f'действующего на основании Устава, с одной стороны, и')
|
||||||
|
_add_para(doc, f'{party1}, именуемое в дальнейшем "Заказчик", в лице')
|
||||||
|
_add_para(doc, f'{party2}, действующего на основании Устава, с другой стороны,')
|
||||||
|
_add_para(doc, f'заключили настоящий Договор о нижеследующем:')
|
||||||
|
|
||||||
|
def _contract_sections(doc):
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, "1. ПРЕДМЕТ ДОГОВОРА")
|
||||||
|
_add_para(doc, "1.1. Исполнитель обязуется оказывать услуги по предоставлению")
|
||||||
|
_add_para(doc, "вычислительных ресурсов ЦОД, а Заказчик обязуется оплачивать")
|
||||||
|
_add_para(doc, "оказанные услуги в порядке и на условиях настоящего Договора.")
|
||||||
|
_add_para(doc, "1.2. Состав и стоимость Услуг определяются в Спецификации")
|
||||||
|
_add_para(doc, "(Приложение № 1), являющейся неотъемлемой частью Договора.")
|
||||||
|
_add_para(doc, "1.3. Место оказания услуг: ЦОД по адресу: г. Москва, ул. 8 Марта, д. 16, стр. 1.")
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, "2. ПРАВА И ОБЯЗАННОСТИ СТОРОН")
|
||||||
|
_add_para(doc, "2.1. Исполнитель обязуется: обеспечить круглосуточный доступ;")
|
||||||
|
_add_para(doc, "поддерживать температурный режим и электропитание; осуществлять охрану.")
|
||||||
|
_add_para(doc, "2.2. Заказчик обязуется: своевременно оплачивать услуги;")
|
||||||
|
_add_para(doc, "соблюдать правила техники безопасности при посещении ЦОД.")
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, "3. СТОИМОСТЬ УСЛУГ И ПОРЯДОК РАСЧЁТОВ")
|
||||||
|
_add_para(doc, "3.1. Стоимость услуг определяется Спецификацией (Приложение № 1).")
|
||||||
|
_add_para(doc, "3.2. Оплата производится ежемесячно до 10 числа месяца,")
|
||||||
|
_add_para(doc, "следующего за отчётным, на основании счёта и акта.")
|
||||||
|
_add_para(doc, "3.3. НДС не облагается (УСН).")
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, "4. СРОК ДЕЙСТВИЯ ДОГОВОРА")
|
||||||
|
_add_para(doc, "4.1. Договор вступает в силу с даты подписания")
|
||||||
|
_add_para(doc, "и действует до 31 декабря текущего года.")
|
||||||
|
_add_para(doc, "4.2. Автоматическая пролонгация на следующий год при отсутствии")
|
||||||
|
_add_para(doc, "письменного уведомления о расторжении за 30 дней.")
|
||||||
|
|
||||||
|
# --- Базовые спецификации ---
|
||||||
|
BASE8 = [
|
||||||
|
["1", "Аренда стойко-места 42U", "45 000,00", "2", "90 000,00", "01.01.2025"],
|
||||||
|
["2", "Блок розеток PDU 3Ф 32A", "3 200,00", "2", "6 400,00", "01.01.2025"],
|
||||||
|
["3", "IP-адрес IPv4 (1 шт.)", "350,00", "8", "2 800,00", "01.01.2025"],
|
||||||
|
["4", "Резервное копирование 100 ГБ", "1 500,00", "1", "1 500,00", "01.01.2025"],
|
||||||
|
["5", "Мониторинг 24/7 базовый", "2 000,00", "1", "2 000,00", "01.01.2025"],
|
||||||
|
["6", "Доступ в интернет 100 Мбит/с", "5 000,00", "1", "5 000,00", "01.01.2025"],
|
||||||
|
["7", "Кросс-соединение медное", "1 200,00", "2", "2 400,00", "01.01.2025"],
|
||||||
|
["8", "Аренда полки 19\"", "8 500,00", "1", "8 500,00", "01.01.2025"],
|
||||||
|
]
|
||||||
|
|
||||||
|
def _gen_contract_spec(num, date):
|
||||||
|
"""Сгенерировать спецификацию к договору (8 строк)."""
|
||||||
|
return _make_spec(num, SPEC8, date)
|
||||||
|
|
||||||
|
def _make_contract(num, date, counterparty_full, counterparty_person,
|
||||||
|
modify_preamble=None, hide_counterparty=False):
|
||||||
|
"""Полноценный договор."""
|
||||||
|
doc = Document()
|
||||||
|
_add_title(doc, "ДОГОВОР")
|
||||||
|
_add_para(doc, f"на оказание технологических услуг № {num}")
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, f"г. Москва {date}")
|
||||||
|
_add_para(doc, "")
|
||||||
|
if hide_counterparty:
|
||||||
|
_add_para(doc, "Настоящий Договор регулирует отношения Сторон по оказанию услуг ЦОД.")
|
||||||
|
elif modify_preamble:
|
||||||
|
modify_preamble(doc, num, date)
|
||||||
|
else:
|
||||||
|
_preamble(doc, num, date, counterparty_full, counterparty_person)
|
||||||
|
_contract_sections(doc)
|
||||||
|
_add_signatures(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
def _make_supplement(parent_num, text, parent_date=None):
|
||||||
|
"""Допсоглашение."""
|
||||||
|
doc = Document()
|
||||||
|
_add_title(doc, "ДОПОЛНИТЕЛЬНОЕ СОГЛАШЕНИЕ")
|
||||||
|
_add_para(doc, f"к Договору № {parent_num}" + (f" от {parent_date}" if parent_date else ""))
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, 'ООО "НУБЕС" (Исполнитель) и Заказчик, именуемые совместно "Стороны",')
|
||||||
|
_add_para(doc, 'заключили настоящее Дополнительное соглашение о нижеследующем:')
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, text)
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, "Настоящее Соглашение вступает в силу с даты подписания Сторонами")
|
||||||
|
_add_para(doc, "и является неотъемлемой частью Договора.")
|
||||||
|
_add_signatures(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
def _make_spec(parent_num, rows, parent_date=None):
|
||||||
|
"""Спецификация с таблицей."""
|
||||||
|
doc = Document()
|
||||||
|
_add_title(doc, "Приложение № 1")
|
||||||
|
_add_para(doc, f"к Договору № {parent_num}" + (f" от {parent_date}" if parent_date else ""))
|
||||||
|
_add_para(doc, "Спецификация и стоимость услуг")
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_table(doc, rows, ["№", "Наименование", "Цена, руб.", "Объем", "Сумма, руб.", "Дата начала"])
|
||||||
|
_add_signatures(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
# ОБЩАЯ спецификация для всех договоров A/B
|
||||||
|
SPEC8 = [
|
||||||
|
["1", "Аренда стойко-места 42U", "45 000,00", "2", "90 000,00", "01.01.2025"],
|
||||||
|
["2", "Блок розеток PDU 3Ф 32A", "3 200,00", "2", "6 400,00", "01.01.2025"],
|
||||||
|
["3", "IP-адрес IPv4 (1 шт.)", "350,00", "8", "2 800,00", "01.01.2025"],
|
||||||
|
["4", "Резервное копирование 100 ГБ", "1 500,00", "1", "1 500,00", "01.01.2025"],
|
||||||
|
["5", "Мониторинг 24/7 базовый", "2 000,00", "1", "2 000,00", "01.01.2025"],
|
||||||
|
["6", "Доступ в интернет 100 Мбит/с", "5 000,00", "1", "5 000,00", "01.01.2025"],
|
||||||
|
["7", "Кросс-соединение медное", "1 200,00", "2", "2 400,00", "01.01.2025"],
|
||||||
|
["8", "Аренда полки 19\"", "8 500,00", "1", "8 500,00", "01.01.2025"],
|
||||||
|
]
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# БЛОК A — Classify (11 кейсов)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def case1_baseline():
|
||||||
|
doc = _make_contract("XXX001-03700", "15 марта 2025 г.",
|
||||||
|
'ЗАО "ТехноПлюс"', 'Генерального директора ФИО')
|
||||||
|
save(doc, "block_a", "договор-XXX001-03700.docx",
|
||||||
|
{"case": 1, "type": "contract", "own_number": "XXX001-03700",
|
||||||
|
"counterparty": 'ЗАО "ТехноПлюс"'})
|
||||||
|
save(_gen_contract_spec("XXX001-03700", "15.03.2025"), "block_a",
|
||||||
|
"спецификация-XXX001-03700_v1.docx",
|
||||||
|
{"case": 1, "type": "specification", "parent_number": "XXX001-03700"})
|
||||||
|
|
||||||
|
def case2_cyrillic():
|
||||||
|
doc = _make_contract("МЭС-123/2024", "10 января 2024 г.",
|
||||||
|
'АО "МосЭнергоСбыт"', 'Генерального директора Петрова А.В.')
|
||||||
|
save(doc, "block_a", "договор-МЭС.docx",
|
||||||
|
{"case": 2, "type": "contract", "own_number": "МЭС-123/2024",
|
||||||
|
"counterparty": 'АО "МосЭнергоСбыт"'})
|
||||||
|
save(_gen_contract_spec("МЭС-123/2024", "10.01.2024"), "block_a",
|
||||||
|
"спецификация-МЭС.docx",
|
||||||
|
{"case": 2, "type": "specification", "parent_number": "МЭС-123/2024"})
|
||||||
|
|
||||||
|
def case3_nonstandard():
|
||||||
|
doc = Document()
|
||||||
|
_add_title(doc, "СОГЛАШЕНИЕ об оказании услуг связи")
|
||||||
|
_add_para(doc, "№ SVC-77 от 01.02.2025")
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, 'ООО "НУБЕС" и ЗАО "Клиент-Сервис" заключили настоящее Соглашение.')
|
||||||
|
_add_signatures(doc)
|
||||||
|
save(doc, "block_a", "договор-нестандарт.docx",
|
||||||
|
{"case": 3, "type": "contract", "own_number": "SVC-77",
|
||||||
|
"counterparty": 'ЗАО "Клиент-Сервис"'})
|
||||||
|
save(_gen_contract_spec("SVC-77", "01.02.2025"), "block_a",
|
||||||
|
"спецификация-SVC-77.docx",
|
||||||
|
{"case": 3, "type": "specification", "parent_number": "SVC-77"})
|
||||||
|
|
||||||
|
def case4_addendum():
|
||||||
|
doc = _make_supplement("XXX003-01300", "Изменить Спецификацию. Утвердить новую редакцию Приложения № 1.", "05.06.2024")
|
||||||
|
save(doc, "block_a", "допник-XXX003-01300.docx",
|
||||||
|
{"case": 4, "type": "supplement", "own_number": "1", "parent_number": "XXX003-01300"})
|
||||||
|
|
||||||
|
def case5_spec():
|
||||||
|
"""Кейс 5: Спецификация (генерируется в case1, здесь — заглушка для нумерации)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def case6_no_counterparty():
|
||||||
|
doc = _make_contract("NC-09", "03 марта 2025 г.",
|
||||||
|
"", "", hide_counterparty=True)
|
||||||
|
save(doc, "block_a", "договор-без-стороны.docx",
|
||||||
|
{"case": 6, "type": "contract", "counterparty": None})
|
||||||
|
save(_gen_contract_spec("NC-09", "03.03.2025"), "block_a",
|
||||||
|
"спецификация-NC-09.docx",
|
||||||
|
{"case": 6, "type": "specification", "parent_number": "NC-09"})
|
||||||
|
|
||||||
|
def case7_date_text():
|
||||||
|
doc = _make_contract("DT-15", '«пятнадцатого» марта 2025 года',
|
||||||
|
'ООО "Ресурс-ЦОД"', 'Генерального директора Сидорова С.С.')
|
||||||
|
save(doc, "block_a", "договор-дата-прописью.docx",
|
||||||
|
{"case": 7, "type": "contract", "doc_date": "2025-03-15",
|
||||||
|
"counterparty": 'ООО "Ресурс-ЦОД"'})
|
||||||
|
save(_gen_contract_spec("DT-15", "15.03.2025"), "block_a",
|
||||||
|
"спецификация-DT-15.docx",
|
||||||
|
{"case": 7, "type": "specification", "parent_number": "DT-15"})
|
||||||
|
|
||||||
|
def case8_two_dates():
|
||||||
|
doc = _make_contract("TD-21", "01 апреля 2025 г.",
|
||||||
|
'АО "ТехноДата"', 'Генерального директора Козлова Д.Д.')
|
||||||
|
# добавить дату окончания
|
||||||
|
_add_para(doc, "4.3. Договор действует до 31.12.2026.")
|
||||||
|
save(doc, "block_a", "договор-две-даты.docx",
|
||||||
|
{"case": 8, "type": "contract", "doc_date": "2025-04-01",
|
||||||
|
"counterparty": 'АО "ТехноДата"'})
|
||||||
|
save(_gen_contract_spec("TD-21", "01.04.2025"), "block_a",
|
||||||
|
"спецификация-TD-21.docx",
|
||||||
|
{"case": 8, "type": "specification", "parent_number": "TD-21"})
|
||||||
|
|
||||||
|
def case9_long_preamble():
|
||||||
|
doc = Document()
|
||||||
|
_add_title(doc, "ДОГОВОР")
|
||||||
|
_add_para(doc, "на оказание технологических услуг № LATE-99")
|
||||||
|
_add_para(doc, "")
|
||||||
|
lorem = ("Настоящий Договор разработан в соответствии с Гражданским кодексом РФ, "
|
||||||
|
"Федеральным законом «О связи», Федеральным законом «Об информации», "
|
||||||
|
"а также иными нормативными правовыми актами Российской Федерации. ") * 8
|
||||||
|
_add_para(doc, lorem[:1600])
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, 'ООО "Поздний Контрагент" и ООО "НУБЕС" заключили Договор.')
|
||||||
|
_contract_sections(doc)
|
||||||
|
_add_signatures(doc)
|
||||||
|
save(doc, "block_a", "договор-длинная-преамбула.docx",
|
||||||
|
{"case": 9, "type": "contract", "own_number": "LATE-99",
|
||||||
|
"counterparty": 'ООО "Поздний Контрагент"'})
|
||||||
|
save(_gen_contract_spec("LATE-99", "15.06.2025"), "block_a",
|
||||||
|
"спецификация-LATE-99.docx",
|
||||||
|
{"case": 9, "type": "specification", "parent_number": "LATE-99"})
|
||||||
|
|
||||||
|
def case10_noisy():
|
||||||
|
doc = Document()
|
||||||
|
_add_title(doc, "ДОГОВОР")
|
||||||
|
_add_para(doc, "Исх. № 456 от 12.05.2025")
|
||||||
|
_add_para(doc, "Лицензия № 789 от 10.01.2025")
|
||||||
|
_add_para(doc, "Счёт № 321 от 15.05.2025")
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, 'ООО "НУБЕС" и ЗАО "МультиНомер" заключили настоящий Договор.')
|
||||||
|
_add_para(doc, "ДОГОВОР № MN-01 от 12.05.2025")
|
||||||
|
_contract_sections(doc)
|
||||||
|
_add_signatures(doc)
|
||||||
|
save(doc, "block_a", "договор-много-номеров.docx",
|
||||||
|
{"case": 10, "type": "contract", "own_number": "MN-01",
|
||||||
|
"counterparty": 'ЗАО "МультиНомер"'})
|
||||||
|
save(_gen_contract_spec("MN-01", "12.05.2025"), "block_a",
|
||||||
|
"спецификация-MN-01.docx",
|
||||||
|
{"case": 10, "type": "specification", "parent_number": "MN-01"})
|
||||||
|
|
||||||
|
def case11_trash():
|
||||||
|
doc = Document()
|
||||||
|
_add_title(doc, "Акт сверки взаимных расчётов")
|
||||||
|
_add_para(doc, "за 1 квартал 2025 г.")
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, "Стороны: ООО «НУБЕС» и ООО «Контрагент»")
|
||||||
|
_add_para(doc, "Сальдо на 01.01.2025: 150 000,00 руб.")
|
||||||
|
_add_para(doc, "Оборот за период: 45 000,00 руб.")
|
||||||
|
_add_para(doc, "Сальдо на 31.03.2025: 195 000,00 руб.")
|
||||||
|
_add_signatures(doc)
|
||||||
|
save(doc, "block_a", "акт-сверки.docx",
|
||||||
|
{"case": 11, "type": "other"})
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# review case line numbers before running!
|
||||||
|
print("hello gen_opus")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# БЛОК B — Group (9 кейсов, ~19 файлов)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def case12_normalize():
|
||||||
|
"""Кейс 12: Разделители нормализуются"""
|
||||||
|
d1 = _make_contract("МЭС-123/2024", "10 января 2024 г.",
|
||||||
|
'АО "МосЭнергоСбыт"', 'Генерального директора Петрова А.В.')
|
||||||
|
save(d1, "block_b", "договор-МЭС-норм.docx", {"case": 12, "own": "МЭС-123/2024", "counterparty": 'АО "МосЭнергоСбыт"'})
|
||||||
|
save(_gen_contract_spec("МЭС-123/2024", "10.01.2024"), "block_b",
|
||||||
|
"спецификация-МЭС-норм.docx", {"case": 12, "type": "specification", "parent_number": "МЭС-123/2024"})
|
||||||
|
|
||||||
|
d2 = _make_supplement("МЭС 123/2024", "Изменить стоимость услуг с 01.02.2024.", "10.01.2024")
|
||||||
|
save(d2, "block_b", "допник-МЭС-норм.docx", {"case": 12, "parent": "МЭС 123/2024"})
|
||||||
|
|
||||||
|
def case13_cyrillic_O():
|
||||||
|
d1 = _make_contract("XXX001-03700", "01 января 2025 г.",
|
||||||
|
'ЗАО "ТехноПлюс"', 'ФИО')
|
||||||
|
save(d1, "block_b", "договор-03700.docx", {"case": 13, "own": "XXX001-03700", "counterparty": 'ЗАО "ТехноПлюс"'})
|
||||||
|
save(_gen_contract_spec("XXX001-03700", "01.01.2025"), "block_b",
|
||||||
|
"спецификация-03700.docx", {"case": 13, "type": "specification", "parent_number": "XXX001-03700"})
|
||||||
|
|
||||||
|
d2 = _make_supplement("XXX001-О3700", "Увеличить количество стоек до 3.") # кириллическая О
|
||||||
|
save(d2, "block_b", "допник-О3700.docx", {"case": 13, "parent": "XXX001-О3700"})
|
||||||
|
|
||||||
|
def case14_latin_C():
|
||||||
|
d1 = _make_contract("CBC-10", "01 февраля 2025 г.",
|
||||||
|
'ООО "СиБиСи"', 'Генерального директора')
|
||||||
|
save(d1, "block_b", "договор-CBC.docx", {"case": 14, "own": "CBC-10", "counterparty": 'ООО "СиБиСи"'})
|
||||||
|
save(_gen_contract_spec("CBC-10", "01.02.2025"), "block_b",
|
||||||
|
"спецификация-CBC.docx", {"case": 14, "type": "specification", "parent_number": "CBC-10"})
|
||||||
|
|
||||||
|
d2 = _make_supplement("СВС-10", "Изменить тарифы.") # кириллическая С
|
||||||
|
save(d2, "block_b", "допник-СВС.docx", {"case": 14, "parent": "СВС-10"})
|
||||||
|
|
||||||
|
def case15_yo():
|
||||||
|
d1 = _make_contract("ЁЖ-5", "01 марта 2025 г.",
|
||||||
|
'ООО "Ёжик-Телеком"', 'Генерального директора')
|
||||||
|
save(d1, "block_b", "договор-ЁЖ.docx", {"case": 15, "own": "ЁЖ-5", "counterparty": 'ООО "Ёжик-Телеком"'})
|
||||||
|
save(_gen_contract_spec("ЁЖ-5", "01.03.2025"), "block_b",
|
||||||
|
"спецификация-ЁЖ.docx", {"case": 15, "type": "specification", "parent_number": "ЁЖ-5"})
|
||||||
|
|
||||||
|
d2 = _make_supplement("ЁЖ-5", "Добавить услугу резервного копирования.")
|
||||||
|
save(d2, "block_b", "допник-ЁЖ.docx", {"case": 15, "parent": "ЁЖ-5"})
|
||||||
|
|
||||||
|
def case16_no_parent():
|
||||||
|
doc = _make_supplement("договора оказания услуг", "Стороны договорились об изменении стоимости услуг.")
|
||||||
|
save(doc, "block_b", "допник-без-родителя.docx", {"case": 16, "type": "supplement", "parent": None})
|
||||||
|
|
||||||
|
def case17_own_match():
|
||||||
|
d1 = _make_contract("GR-50", "01 апреля 2025 г.",
|
||||||
|
'ООО "ГринРайт"', 'Генерального директора')
|
||||||
|
save(d1, "block_b", "договор-GR50.docx", {"case": 17, "own": "GR-50", "counterparty": 'ООО "ГринРайт"'})
|
||||||
|
save(_gen_contract_spec("GR-50", "01.04.2025"), "block_b",
|
||||||
|
"спецификация-GR50.docx", {"case": 17, "type": "specification", "parent_number": "GR-50"})
|
||||||
|
|
||||||
|
d2 = _make_supplement("GR-50", "Изменить спецификацию с 15.04.2025.") # own=GR-50
|
||||||
|
save(d2, "block_b", "допник-GR50.docx", {"case": 17, "own": "GR-50"})
|
||||||
|
|
||||||
|
def case18_collision():
|
||||||
|
d1 = _make_contract("AB-12", "01 мая 2025 г.",
|
||||||
|
'ООО "Альфа-Бета"', 'Генерального директора')
|
||||||
|
save(d1, "block_b", "договор-AB12.docx", {"case": 18, "own": "AB-12", "counterparty": 'ООО "Альфа-Бета"'})
|
||||||
|
save(_gen_contract_spec("AB-12", "01.05.2025"), "block_b",
|
||||||
|
"спецификация-AB12.docx", {"case": 18, "type": "specification", "parent_number": "AB-12"})
|
||||||
|
|
||||||
|
d2 = _make_contract("A-B12", "01 мая 2025 г.",
|
||||||
|
'ООО "А-Бет"', 'Генерального директора')
|
||||||
|
save(d2, "block_b", "договор-A-B12.docx", {"case": 18, "own": "A-B12", "counterparty": 'ООО "А-Бет"'})
|
||||||
|
save(_gen_contract_spec("A-B12", "01.05.2025"), "block_b",
|
||||||
|
"спецификация-A-B12.docx", {"case": 18, "type": "specification", "parent_number": "A-B12"})
|
||||||
|
|
||||||
|
d3 = _make_supplement("AB12", "Изменить условия.")
|
||||||
|
save(d3, "block_b", "допник-AB12.docx", {"case": 18, "parent": "AB12"})
|
||||||
|
|
||||||
|
def case19_family():
|
||||||
|
d1 = _make_contract("FAM-01", "10 января 2025 г.",
|
||||||
|
'ЗАО "Фэмили-Групп"', 'Генерального директора')
|
||||||
|
save(d1, "block_b", "договор-FAM.docx", {"case": 19, "date": "2025-01-10", "counterparty": 'ЗАО "Фэмили-Групп"'})
|
||||||
|
|
||||||
|
d2 = _make_spec("FAM-01", SPEC8, "01.02.2025")
|
||||||
|
save(d2, "block_b", "спека-FAM.docx", {"case": 19, "date": "2025-02-01"})
|
||||||
|
|
||||||
|
d3 = _make_supplement("FAM-01", "Дополнительное соглашение № 1. Увеличить количество стоек до 3.")
|
||||||
|
save(d3, "block_b", "допник-1-FAM.docx", {"case": 19, "date": "2025-03-01"})
|
||||||
|
|
||||||
|
d4 = _make_supplement("FAM-01", "Дополнительное соглашение № 2. Добавить услугу резервного копирования.")
|
||||||
|
save(d4, "block_b", "допник-2-FAM.docx", {"case": 19, "date": "2025-05-01"})
|
||||||
|
|
||||||
|
def case20_suffix():
|
||||||
|
doc = _make_supplement("XXX003-01300", "Изменить спецификацию. Утвердить новую редакцию.")
|
||||||
|
save(doc, "block_b", "допник-1-XXX003-01300_2.docx", {"case": 20, "own": "1", "parent": "XXX003-01300"})
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# БЛОК C — Compare (10 кейсов, ~20 файлов, 6-10 строк)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _compare_spec_v1(parent_num, rows):
|
||||||
|
"""Спецификация v1 для compare-кейса."""
|
||||||
|
return _make_spec(parent_num, rows)
|
||||||
|
|
||||||
|
def _compare_spec_v2(parent_num, rows):
|
||||||
|
"""Изменённая спецификация для compare-кейса (подаётся как допник с таблицей)."""
|
||||||
|
doc = Document()
|
||||||
|
_add_title(doc, "Приложение № 1 (новая редакция)")
|
||||||
|
_add_para(doc, f"к Дополнительному соглашению к Договору № {parent_num}")
|
||||||
|
_add_para(doc, "Спецификация и стоимость услуг")
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_table(doc, rows, ["№", "Наименование", "Цена, руб.", "Объем", "Сумма, руб.", "Дата начала"])
|
||||||
|
_add_signatures(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
# ---- Базовая спецификация 8 строк для ВСЕХ compare-кейсов ----
|
||||||
|
# (одинаковая v1, меняется только v2 по кейсу)
|
||||||
|
|
||||||
|
SPEC_V1 = [
|
||||||
|
["1", "Аренда стойко-места 42U", "45 000,00", "2", "90 000,00", "01.01.2025"],
|
||||||
|
["2", "Блок розеток PDU 3Ф 32A", "3 200,00", "2", "6 400,00", "01.01.2025"],
|
||||||
|
["3", "IP-адрес IPv4 (1 шт.)", "350,00", "8", "2 800,00", "01.01.2025"],
|
||||||
|
["4", "Резервное копирование 100 ГБ", "1 500,00", "1", "1 500,00", "01.01.2025"],
|
||||||
|
["5", "Мониторинг 24/7 базовый", "2 000,00", "1", "2 000,00", "01.01.2025"],
|
||||||
|
["6", "Доступ в интернет 100 Мбит/с", "5 000,00", "1", "5 000,00", "01.01.2025"],
|
||||||
|
["7", "Кросс-соединение медное", "1 200,00", "2", "2 400,00", "01.01.2025"],
|
||||||
|
["8", "Аренда полки 19\"", "8 500,00", "1", "8 500,00", "01.01.2025"],
|
||||||
|
]
|
||||||
|
|
||||||
|
# CASE 21: цена +1 копейка → UPDATE (1 строка)
|
||||||
|
SPEC21_V2 = [
|
||||||
|
["1", "Аренда стойко-места 42U", "45 000,01", "2", "90 000,02", "01.01.2025"],
|
||||||
|
["2", "Блок розеток PDU 3Ф 32A", "3 200,00", "2", "6 400,00", "01.01.2025"],
|
||||||
|
["3", "IP-адрес IPv4 (1 шт.)", "350,00", "8", "2 800,00", "01.01.2025"],
|
||||||
|
["4", "Резервное копирование 100 ГБ", "1 500,00", "1", "1 500,00", "01.01.2025"],
|
||||||
|
["5", "Мониторинг 24/7 базовый", "2 000,00", "1", "2 000,00", "01.01.2025"],
|
||||||
|
["6", "Доступ в интернет 100 Мбит/с", "5 000,00", "1", "5 000,00", "01.01.2025"],
|
||||||
|
["7", "Кросс-соединение медное", "1 200,00", "2", "2 400,00", "01.01.2025"],
|
||||||
|
["8", "Аренда полки 19\"", "8 500,00", "1", "8 500,00", "01.01.2025"],
|
||||||
|
]
|
||||||
|
|
||||||
|
# CASE 22: объём → 0 для услуги 3 (DELETE) и цена меняется для услуги 3 (UPDATE)
|
||||||
|
SPEC22_V2 = [
|
||||||
|
["1", "Аренда стойко-места 42U", "45 000,00", "2", "90 000,00", "01.01.2025"],
|
||||||
|
["2", "Блок розеток PDU 3Ф 32A", "3 200,00", "2", "6 400,00", "01.01.2025"],
|
||||||
|
# ["3", "IP-адрес IPv4 (1 шт.)"] — DELETE
|
||||||
|
["4", "Резервное копирование 100 ГБ", "1 500,00", "1", "1 500,00", "01.01.2025"],
|
||||||
|
["5", "Мониторинг 24/7 базовый", "2 000,00", "1", "2 000,00", "01.01.2025"],
|
||||||
|
["6", "Доступ в интернет 100 Мбит/с", "5 000,00", "1", "5 000,00", "01.01.2025"],
|
||||||
|
["7", "Кросс-соединение медное", "1 200,00", "2", "2 400,00", "01.01.2025"],
|
||||||
|
["8", "Аренда полки 19\"", "8 500,00", "1", "8 500,00", "01.01.2025"],
|
||||||
|
]
|
||||||
|
|
||||||
|
# CASE 23: переименование строки 5
|
||||||
|
SPEC23_V2 = [
|
||||||
|
["1", "Аренда стойко-места 42U", "45 000,00", "2", "90 000,00", "01.01.2025"],
|
||||||
|
["2", "Блок розеток PDU 3Ф 32A", "3 200,00", "2", "6 400,00", "01.01.2025"],
|
||||||
|
["3", "IP-адрес IPv4 (1 шт.)", "350,00", "8", "2 800,00", "01.01.2025"],
|
||||||
|
["4", "Резервное копирование 100 ГБ", "1 500,00", "1", "1 500,00", "01.01.2025"],
|
||||||
|
["5", "Мониторинг 24/7 расширенный", "3 000,00", "1", "3 000,00", "01.05.2025"],
|
||||||
|
["6", "Доступ в интернет 100 Мбит/с", "5 000,00", "1", "5 000,00", "01.01.2025"],
|
||||||
|
["7", "Кросс-соединение медное", "1 200,00", "2", "2 400,00", "01.01.2025"],
|
||||||
|
["8", "Аренда полки 19\"", "8 500,00", "1", "8 500,00", "01.01.2025"],
|
||||||
|
]
|
||||||
|
|
||||||
|
# CASE 24: полная замена — 3 строки совсем другие
|
||||||
|
SPEC24_V2 = [
|
||||||
|
["1", "Выделенный сервер Dell R740xd", "85 000,00", "1", "85 000,00", "01.05.2025"],
|
||||||
|
["2", "Лицензия VMware vSphere 8", "12 000,00", "2", "24 000,00", "01.05.2025"],
|
||||||
|
["3", "Резервное копирование 1 ТБ", "4 000,00", "1", "4 000,00", "01.05.2025"],
|
||||||
|
]
|
||||||
|
|
||||||
|
# CASE 25: ADD — +2 строки
|
||||||
|
SPEC25_V2 = [
|
||||||
|
["1", "Аренда стойко-места 42U", "45 000,00", "2", "90 000,00", "01.01.2025"],
|
||||||
|
["2", "Блок розеток PDU 3Ф 32A", "3 200,00", "2", "6 400,00", "01.01.2025"],
|
||||||
|
["3", "IP-адрес IPv4 (1 шт.)", "350,00", "8", "2 800,00", "01.01.2025"],
|
||||||
|
["4", "Резервное копирование 100 ГБ", "1 500,00", "1", "1 500,00", "01.01.2025"],
|
||||||
|
["5", "Мониторинг 24/7 базовый", "2 000,00", "1", "2 000,00", "01.01.2025"],
|
||||||
|
["6", "Доступ в интернет 100 Мбит/с", "5 000,00", "1", "5 000,00", "01.01.2025"],
|
||||||
|
["7", "Кросс-соединение медное", "1 200,00", "2", "2 400,00", "01.01.2025"],
|
||||||
|
["8", "Аренда полки 19\"", "8 500,00", "1", "8 500,00", "01.01.2025"],
|
||||||
|
# ADD:
|
||||||
|
["9", "Резервное копирование 1 ТБ", "4 000,00", "1", "4 000,00", "01.06.2025"],
|
||||||
|
["10", "DDoS-защита 10 Гбит/с", "15 000,00", "1", "15 000,00", "01.06.2025"],
|
||||||
|
]
|
||||||
|
|
||||||
|
# CASE 26: DELETE — без пункта 7 и 8
|
||||||
|
SPEC26_V2 = [
|
||||||
|
["1", "Аренда стойко-места 42U", "45 000,00", "2", "90 000,00", "01.01.2025"],
|
||||||
|
["2", "Блок розеток PDU 3Ф 32A", "3 200,00", "2", "6 400,00", "01.01.2025"],
|
||||||
|
["3", "IP-адрес IPv4 (1 шт.)", "350,00", "8", "2 800,00", "01.01.2025"],
|
||||||
|
["4", "Резервное копирование 100 ГБ", "1 500,00", "1", "1 500,00", "01.01.2025"],
|
||||||
|
["5", "Мониторинг 24/7 базовый", "2 000,00", "1", "2 000,00", "01.01.2025"],
|
||||||
|
["6", "Доступ в интернет 100 Мбит/с", "5 000,00", "1", "5 000,00", "01.01.2025"],
|
||||||
|
# ["7", "Кросс-соединение медное"] — DELETE
|
||||||
|
# ["8", "Аренда полки 19\""] — DELETE
|
||||||
|
]
|
||||||
|
|
||||||
|
# CASE 27: только объём — 3→8 для услуги 3
|
||||||
|
SPEC27_V2 = [
|
||||||
|
["1", "Аренда стойко-места 42U", "45 000,00", "2", "90 000,00", "01.01.2025"],
|
||||||
|
["2", "Блок розеток PDU 3Ф 32A", "3 200,00", "2", "6 400,00", "01.01.2025"],
|
||||||
|
["3", "IP-адрес IPv4 (1 шт.)", "350,00", "12", "4 200,00", "01.08.2025"],
|
||||||
|
["4", "Резервное копирование 100 ГБ", "1 500,00", "1", "1 500,00", "01.01.2025"],
|
||||||
|
["5", "Мониторинг 24/7 базовый", "2 000,00", "1", "2 000,00", "01.01.2025"],
|
||||||
|
["6", "Доступ в интернет 100 Мбит/с", "5 000,00", "1", "5 000,00", "01.01.2025"],
|
||||||
|
["7", "Кросс-соединение медное", "1 200,00", "2", "2 400,00", "01.01.2025"],
|
||||||
|
["8", "Аренда полки 19\"", "8 500,00", "1", "8 500,00", "01.01.2025"],
|
||||||
|
]
|
||||||
|
|
||||||
|
# CASE 28: UNRESOLVED — услуга-призрак (не совпадает ни с одной v1)
|
||||||
|
SPEC28_V2 = [
|
||||||
|
["1", "Аренда стойко-места 42U", "45 000,00", "2", "90 000,00", "01.01.2025"],
|
||||||
|
["2", "Блок розеток PDU 3Ф 32A", "3 200,00", "2", "6 400,00", "01.01.2025"],
|
||||||
|
["3", "IP-адрес IPv4 (1 шт.)", "350,00", "8", "2 800,00", "01.01.2025"],
|
||||||
|
["4", "Резервное копирование 100 ГБ", "1 500,00", "1", "1 500,00", "01.01.2025"],
|
||||||
|
["5", "Мониторинг 24/7 базовый", "2 000,00", "1", "2 000,00", "01.01.2025"],
|
||||||
|
["6", "Доступ в интернет 100 Мбит/с", "5 000,00", "1", "5 000,00", "01.01.2025"],
|
||||||
|
["7", "Кросс-соединение медное", "1 200,00", "2", "2 400,00", "01.01.2025"],
|
||||||
|
["8", "Аренда полки 19\"", "8 500,00", "1", "8 500,00", "01.01.2025"],
|
||||||
|
# ADD — услуга-призрак (нет в v1)
|
||||||
|
["9", "VPN-туннель site-to-site", "3 000,00", "1", "3 000,00", "01.03.2025"],
|
||||||
|
]
|
||||||
|
|
||||||
|
# CASE 29: формат чисел (пробелы)
|
||||||
|
SPEC29_V2 = [
|
||||||
|
["1", "Аренда стойко-места 42U", "45 500,00", "2", "91 000,00", "01.01.2025"],
|
||||||
|
["2", "Блок розеток PDU 3Ф 32A", "3 200,00", "2", "6 400,00", "01.01.2025"],
|
||||||
|
["3", "IP-адрес IPv4 (1 шт.)", "350,00", "8", "2 800,00", "01.01.2025"],
|
||||||
|
["4", "Резервное копирование 100 ГБ", "1 500,00", "1", "1 500,00", "01.01.2025"],
|
||||||
|
["5", "Мониторинг 24/7 базовый", "2 000,00", "1", "2 000,00", "01.01.2025"],
|
||||||
|
["6", "Доступ в интернет 100 Мбит/с", "5 000,00", "1", "5 000,00", "01.01.2025"],
|
||||||
|
["7", "Кросс-соединение медное", "1 200,00", "2", "2 400,00", "01.01.2025"],
|
||||||
|
["8", "Аренда полки 19\"", "8 500,00", "1", "8 500,00", "01.01.2025"],
|
||||||
|
]
|
||||||
|
|
||||||
|
# CASE 30: робастность JSON — та же specs что и 21
|
||||||
|
SPEC30_V2 = [
|
||||||
|
["1", "Аренда стойко-места 42U", "51 000,00", "2", "102 000,00", "01.10.2025"],
|
||||||
|
["2", "Блок розеток PDU 3Ф 32A", "3 200,00", "2", "6 400,00", "01.01.2025"],
|
||||||
|
["3", "IP-адрес IPv4 (1 шт.)", "350,00", "8", "2 800,00", "01.01.2025"],
|
||||||
|
["4", "Резервное копирование 100 ГБ", "1 500,00", "1", "1 500,00", "01.01.2025"],
|
||||||
|
["5", "Мониторинг 24/7 базовый", "2 000,00", "1", "2 000,00", "01.01.2025"],
|
||||||
|
["6", "Доступ в интернет 100 Мбит/с", "5 000,00", "1", "5 000,00", "01.01.2025"],
|
||||||
|
["7", "Кросс-соединение медное", "1 200,00", "2", "2 400,00", "01.01.2025"],
|
||||||
|
["8", "Аренда полки 19\"", "8 500,00", "1", "8 500,00", "01.01.2025"],
|
||||||
|
]
|
||||||
|
|
||||||
|
def case21_kopeck():
|
||||||
|
save(_compare_spec_v1("CMP-021", SPEC_V1), "block_c", "спека-цена-v1.docx",
|
||||||
|
{"case": 21, "version": 1, "parent_number": "CMP-021"})
|
||||||
|
save(_compare_spec_v2("CMP-021", SPEC21_V2), "block_c", "допник-цена.docx",
|
||||||
|
{"case": 21, "type": "supplement", "parent_number": "CMP-021"})
|
||||||
|
|
||||||
|
def case22_zero_qty():
|
||||||
|
save(_compare_spec_v1("CMP-022", SPEC_V1), "block_c", "спека-обнуление-v1.docx",
|
||||||
|
{"case": 22, "version": 1, "parent_number": "CMP-022"})
|
||||||
|
save(_compare_spec_v2("CMP-022", SPEC22_V2), "block_c", "допник-обнуление.docx",
|
||||||
|
{"case": 22, "type": "supplement", "parent_number": "CMP-022"})
|
||||||
|
|
||||||
|
def case23_rename():
|
||||||
|
save(_compare_spec_v1("CMP-023", SPEC_V1), "block_c", "спека-переименование-v1.docx",
|
||||||
|
{"case": 23, "version": 1, "parent_number": "CMP-023"})
|
||||||
|
save(_compare_spec_v2("CMP-023", SPEC23_V2), "block_c", "допник-переименование.docx",
|
||||||
|
{"case": 23, "type": "supplement", "parent_number": "CMP-023"})
|
||||||
|
|
||||||
|
def case24_full_replace():
|
||||||
|
save(_compare_spec_v1("CMP-024", SPEC_V1), "block_c", "спека-полная-v1.docx",
|
||||||
|
{"case": 24, "version": 1, "parent_number": "CMP-024"})
|
||||||
|
save(_compare_spec_v2("CMP-024", SPEC24_V2), "block_c", "допник-новая-редакция.docx",
|
||||||
|
{"case": 24, "type": "supplement", "parent_number": "CMP-024"})
|
||||||
|
|
||||||
|
def case25_add():
|
||||||
|
save(_compare_spec_v1("CMP-025", SPEC_V1), "block_c", "спека-добавление-v1.docx",
|
||||||
|
{"case": 25, "version": 1, "parent_number": "CMP-025"})
|
||||||
|
save(_compare_spec_v2("CMP-025", SPEC25_V2), "block_c", "допник-добавление.docx",
|
||||||
|
{"case": 25, "type": "supplement", "parent_number": "CMP-025"})
|
||||||
|
|
||||||
|
def case26_delete():
|
||||||
|
save(_compare_spec_v1("CMP-026", SPEC_V1), "block_c", "спека-удаление-v1.docx",
|
||||||
|
{"case": 26, "version": 1, "parent_number": "CMP-026"})
|
||||||
|
save(_compare_spec_v2("CMP-026", SPEC26_V2), "block_c", "допник-удаление.docx",
|
||||||
|
{"case": 26, "type": "supplement", "parent_number": "CMP-026"})
|
||||||
|
|
||||||
|
def case27_qty_only():
|
||||||
|
save(_compare_spec_v1("CMP-027", SPEC_V1), "block_c", "спека-объём-v1.docx",
|
||||||
|
{"case": 27, "version": 1, "parent_number": "CMP-027"})
|
||||||
|
save(_compare_spec_v2("CMP-027", SPEC27_V2), "block_c", "допник-объём.docx",
|
||||||
|
{"case": 27, "type": "supplement", "parent_number": "CMP-027"})
|
||||||
|
|
||||||
|
def case28_unresolved():
|
||||||
|
save(_compare_spec_v1("CMP-028", SPEC_V1), "block_c", "спека-призрак-v1.docx",
|
||||||
|
{"case": 28, "version": 1, "parent_number": "CMP-028"})
|
||||||
|
save(_compare_spec_v2("CMP-028", SPEC28_V2), "block_c", "допник-призрак.docx",
|
||||||
|
{"case": 28, "type": "supplement", "parent_number": "CMP-028"})
|
||||||
|
|
||||||
|
def case29_format():
|
||||||
|
save(_compare_spec_v1("CMP-029", SPEC_V1), "block_c", "спека-формат-v1.docx",
|
||||||
|
{"case": 29, "version": 1, "parent_number": "CMP-029"})
|
||||||
|
save(_compare_spec_v2("CMP-029", SPEC29_V2), "block_c", "допник-формат-чисел.docx",
|
||||||
|
{"case": 29, "type": "supplement", "parent_number": "CMP-029"})
|
||||||
|
|
||||||
|
def case30_robust():
|
||||||
|
save(_compare_spec_v1("CMP-030", SPEC_V1), "block_c", "спека-роб-v1.docx",
|
||||||
|
{"case": 30, "version": 1, "parent_number": "CMP-030"})
|
||||||
|
save(_compare_spec_v2("CMP-030", SPEC30_V2), "block_c", "допник-роб.docx",
|
||||||
|
{"case": 30, "type": "supplement", "parent_number": "CMP-030"})
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Main
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 50)
|
||||||
|
print("Генератор кейсов Опуса")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
print("\n── Блок A: Classify ──")
|
||||||
|
case1_baseline(); case2_cyrillic(); case3_nonstandard()
|
||||||
|
case4_addendum(); case5_spec(); case6_no_counterparty()
|
||||||
|
case7_date_text(); case8_two_dates(); case9_long_preamble()
|
||||||
|
case10_noisy(); case11_trash()
|
||||||
|
|
||||||
|
print("\n── Блок B: Group ──")
|
||||||
|
case12_normalize(); case13_cyrillic_O(); case14_latin_C()
|
||||||
|
case15_yo(); case16_no_parent(); case17_own_match()
|
||||||
|
case18_collision(); case19_family(); case20_suffix()
|
||||||
|
|
||||||
|
print("\n── Блок C: Compare ──")
|
||||||
|
case21_kopeck(); case22_zero_qty(); case23_rename()
|
||||||
|
case24_full_replace(); case25_add(); case26_delete()
|
||||||
|
case27_qty_only(); case28_unresolved(); case29_format()
|
||||||
|
case30_robust()
|
||||||
|
|
||||||
|
manifest_path = os.path.join(OUT, "manifest.json")
|
||||||
|
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(MANIFEST, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f"\n{'=' * 50}")
|
||||||
|
print(f"Всего файлов: {len(MANIFEST)}")
|
||||||
|
print(f"Вывод: {OUT}/")
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+126
-17
@@ -18,7 +18,7 @@ MANIFEST = {}
|
|||||||
def ensure_dir(path):
|
def ensure_dir(path):
|
||||||
os.makedirs(path, exist_ok=True)
|
os.makedirs(path, exist_ok=True)
|
||||||
|
|
||||||
def save_doc(doc, path):
|
def save_doc(doc, path, comment=""):
|
||||||
doc.save(path)
|
doc.save(path)
|
||||||
print(f" ✓ {os.path.basename(path)}")
|
print(f" ✓ {os.path.basename(path)}")
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ def generate_valid():
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ── Допники (1-3) ──
|
# ── Допники (1-3) ──
|
||||||
n_add = random.randint(1, 3)
|
n_add = random.randint(2, 4)
|
||||||
for ai in range(1, n_add + 1):
|
for ai in range(1, n_add + 1):
|
||||||
inst, itot, itot_s = pools.pick_services(random.randint(1, 3))
|
inst, itot, itot_s = pools.pick_services(random.randint(1, 3))
|
||||||
mon, mtot, mtot_s = pools.pick_services(random.randint(2, 5))
|
mon, mtot, mtot_s = pools.pick_services(random.randint(2, 5))
|
||||||
@@ -272,8 +272,11 @@ def generate_errors():
|
|||||||
corrupt.make_giant_docx(os.path.join(out, "giant_500.docx"), 500)
|
corrupt.make_giant_docx(os.path.join(out, "giant_500.docx"), 500)
|
||||||
MANIFEST["giant_500.docx"] = {"type": "error", "error": "giant", "status": "expected_slow"}
|
MANIFEST["giant_500.docx"] = {"type": "error", "error": "giant", "status": "expected_slow"}
|
||||||
|
|
||||||
# Мусор (3 шт.)
|
# Мусор (11 шт.)
|
||||||
trash_types = ["Счёт-фактура", "Письмо-запрос", "Коммерческое предложение"]
|
trash_types = ["Счёт-фактура", "Письмо-запрос", "Коммерческое предложение",
|
||||||
|
"Акт выполненных работ", "Накладная", "Платёжное поручение",
|
||||||
|
"Счёт на оплату", "Договор аренды офиса", "Служебная записка",
|
||||||
|
"Прайс-лист", "Гарантийное письмо"]
|
||||||
for i, tt in enumerate(trash_types):
|
for i, tt in enumerate(trash_types):
|
||||||
fname = f"trash_{i+1}.docx"
|
fname = f"trash_{i+1}.docx"
|
||||||
corrupt.make_trash_docx(os.path.join(out, fname), tt)
|
corrupt.make_trash_docx(os.path.join(out, fname), tt)
|
||||||
@@ -293,7 +296,57 @@ def generate_errors():
|
|||||||
save_doc(templates.build_spec(ctx_orphan), os.path.join(out, "orphan_spec.docx"))
|
save_doc(templates.build_spec(ctx_orphan), os.path.join(out, "orphan_spec.docx"))
|
||||||
MANIFEST["orphan_spec.docx"] = {"type": "error", "error": "orphan", "status": "expected_group_unresolved"}
|
MANIFEST["orphan_spec.docx"] = {"type": "error", "error": "orphan", "status": "expected_group_unresolved"}
|
||||||
|
|
||||||
print(f" Сгенерировано ошибочных: 13")
|
print(f" Сгенерировано ошибочных: 21 + 2 orphan")
|
||||||
|
|
||||||
|
# Добавим ещё сирот и edge-кейсов
|
||||||
|
for vi in range(3):
|
||||||
|
onum = f"GHOST{vi}"
|
||||||
|
osvcs, _, _ = pools.pick_services(2)
|
||||||
|
ctx_o = {"company": pools.COMPANIES[vi], "number": onum, "date": pools.random_date(),
|
||||||
|
"contract_date": pools.random_date(), "version": 1,
|
||||||
|
"services": osvcs, "total": sum(s["sum"] for s in osvcs),
|
||||||
|
"total_str": pools.format_price(sum(s["sum"] for s in osvcs)),
|
||||||
|
"label": "Абонентские услуги",
|
||||||
|
"comment": f"Спецификация-сирота {onum}: нет родительского договора."}
|
||||||
|
ctx_o["total"] = round(sum(s["sum"] for s in osvcs), 2)
|
||||||
|
ctx_o["total_str"] = pools.format_price(ctx_o["total"])
|
||||||
|
fn = f"orphan_spec_{vi+1}.docx"
|
||||||
|
save_doc(templates.build_spec(ctx_o), os.path.join(out, fn))
|
||||||
|
MANIFEST[fn] = {"type": "error", "error": "orphan", "number": onum, "status": "expected_group_unresolved"}
|
||||||
|
|
||||||
|
# Договор без таблицы реквизитов (edge case для парсера)
|
||||||
|
doc_no_table = templates.build_contract(ctx_tmp)
|
||||||
|
# Удаляем таблицы
|
||||||
|
for t in doc_no_table.tables:
|
||||||
|
t._element.getparent().remove(t._element)
|
||||||
|
save_doc(doc_no_table, os.path.join(out, "contract_no_rekv.docx"))
|
||||||
|
MANIFEST["contract_no_rekv.docx"] = {"type": "error", "error": "no_rekv_table", "status": "expected_parse_ok"}
|
||||||
|
|
||||||
|
print(f" ✓ доп. ошибки: +5")
|
||||||
|
|
||||||
|
# ── Кириллический контракт (ХХХ002) ──
|
||||||
|
out_v = os.path.join(OUT, "valid")
|
||||||
|
kc = pools.COMPANIES[-1] # ХХХ002
|
||||||
|
knum = "03700_K"
|
||||||
|
kdate = "15.03.2026"
|
||||||
|
ctx_k = {"company": kc, "number": knum, "date": kdate, "company_idx": 99,
|
||||||
|
"comment": f"Кириллический контракт {kc['prefix']}-{knum}. Edge-case: кириллица в названии компании."}
|
||||||
|
fk = f"договор-{kc['prefix']}-{knum}.docx"
|
||||||
|
save_doc(templates.build_contract(ctx_k), os.path.join(out_v, fk))
|
||||||
|
MANIFEST[fk] = {"type": "contract", "company": kc["prefix"], "number": knum, "date": kdate, "status": "valid"}
|
||||||
|
|
||||||
|
# Спека к кириллическому
|
||||||
|
ksvcs, ktot, ktot_s = pools.pick_services(6)
|
||||||
|
for s in ksvcs: s["date_start"] = pools.random_date(2026, 3, 3)
|
||||||
|
ctx_ks = {"company": kc, "number": knum, "date": kdate, "contract_date": kdate, "version": 1,
|
||||||
|
"services": ksvcs, "total": ktot, "total_str": ktot_s,
|
||||||
|
"label": f"Абонентские услуги",
|
||||||
|
"comment": f"Спецификация кириллического контракта {knum}."}
|
||||||
|
fks = f"спецификация-{kc['prefix']}-{knum}.docx"
|
||||||
|
save_doc(templates.build_spec(ctx_ks), os.path.join(out_v, fks))
|
||||||
|
MANIFEST[fks] = {"type": "spec", "company": kc["prefix"], "number": knum, "version": 1, "status": "valid"}
|
||||||
|
|
||||||
|
print(f" ✓ кириллический контракт: +2 файла")
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
@@ -381,15 +434,32 @@ def generate_zips():
|
|||||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
for f in files_for_zip:
|
for f in files_for_zip:
|
||||||
zf.write(os.path.join(valid_dir, f), f)
|
zf.write(os.path.join(valid_dir, f), f)
|
||||||
print(f" ✓ ZIP: {os.path.basename(zip_path)} ({len(files_for_zip)} файлов)")
|
# ZIP: все файлы контракта XXX005
|
||||||
MANIFEST[os.path.basename(zip_path)] = {"type": "zip", "files": files_for_zip, "status": "valid"}
|
files5 = [f for f in os.listdir(valid_dir) if "XXX005" in f]
|
||||||
|
if files5:
|
||||||
|
zp = os.path.join(out, "contract_XXX005.zip")
|
||||||
|
with zipfile.ZipFile(zp, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for f in files5:
|
||||||
|
zf.write(os.path.join(valid_dir, f), f)
|
||||||
|
print(f" ✓ ZIP XXX005: {os.path.basename(zp)} ({len(files5)} файлов)")
|
||||||
|
MANIFEST[os.path.basename(zp)] = {"type": "zip", "files": files5, "status": "valid"}
|
||||||
|
|
||||||
|
# ZIP: только спецификации (без договора)
|
||||||
|
specs = [f for f in os.listdir(valid_dir) if f.startswith("спецификация")][:4]
|
||||||
|
if specs:
|
||||||
|
zp2 = os.path.join(out, "only_specs.zip")
|
||||||
|
with zipfile.ZipFile(zp2, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for f in specs:
|
||||||
|
zf.write(os.path.join(valid_dir, f), f)
|
||||||
|
print(f" ✓ ZIP только спеки: {os.path.basename(zp2)} ({len(specs)} файлов)")
|
||||||
|
MANIFEST[os.path.basename(zp2)] = {"type": "zip", "specs_only": True, "status": "valid"}
|
||||||
|
|
||||||
# ZIP с дубликатами имён внутри
|
# ZIP с дубликатами имён внутри
|
||||||
if len(files_for_zip) >= 2:
|
if len(files_for_zip) >= 1:
|
||||||
zip_path2 = os.path.join(out, "duplicates_inside.zip")
|
zip_path2 = os.path.join(out, "duplicates_inside.zip")
|
||||||
with zipfile.ZipFile(zip_path2, 'w', zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(zip_path2, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
zf.write(os.path.join(valid_dir, files_for_zip[0]), files_for_zip[0])
|
zf.write(os.path.join(valid_dir, files_for_zip[0]), files_for_zip[0])
|
||||||
zf.write(os.path.join(valid_dir, files_for_zip[0]), files_for_zip[0]) # дубликат!
|
zf.write(os.path.join(valid_dir, files_for_zip[0]), files_for_zip[0])
|
||||||
print(f" ✓ ZIP с дубликатами: {os.path.basename(zip_path2)}")
|
print(f" ✓ ZIP с дубликатами: {os.path.basename(zip_path2)}")
|
||||||
MANIFEST[os.path.basename(zip_path2)] = {"type": "zip", "duplicates": True, "status": "valid"}
|
MANIFEST[os.path.basename(zip_path2)] = {"type": "zip", "duplicates": True, "status": "valid"}
|
||||||
|
|
||||||
@@ -403,6 +473,8 @@ def generate_zips():
|
|||||||
print(f" ✓ ZIP с битым: {os.path.basename(zip_path3)}")
|
print(f" ✓ ZIP с битым: {os.path.basename(zip_path3)}")
|
||||||
MANIFEST[os.path.basename(zip_path3)] = {"type": "zip", "has_error": True, "status": "valid"}
|
MANIFEST[os.path.basename(zip_path3)] = {"type": "zip", "has_error": True, "status": "valid"}
|
||||||
|
|
||||||
|
print(f" ✓ ZIP-ы: 5 шт.")
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
# Дубликаты
|
# Дубликаты
|
||||||
@@ -418,16 +490,28 @@ def generate_duplicates():
|
|||||||
|
|
||||||
files = [f for f in os.listdir(valid_dir) if f.endswith('.docx')]
|
files = [f for f in os.listdir(valid_dir) if f.endswith('.docx')]
|
||||||
if len(files) >= 3:
|
if len(files) >= 3:
|
||||||
# Точная копия (другое имя)
|
# Точная копия (другое имя) × 3
|
||||||
src = os.path.join(valid_dir, files[0])
|
for i in range(min(3, len(files))):
|
||||||
shutil.copy(src, os.path.join(out, "copy_of_" + files[0]))
|
src = os.path.join(valid_dir, files[i])
|
||||||
MANIFEST["copy_of_" + files[0]] = {"type": "duplicate", "original": files[0], "kind": "content_copy"}
|
dst = os.path.join(out, f"copy_{i+1}_" + files[i])
|
||||||
|
shutil.copy(src, dst)
|
||||||
|
MANIFEST[os.path.basename(dst)] = {"type": "duplicate", "original": files[i], "kind": "content_copy"}
|
||||||
|
|
||||||
# Одинаковое имя, разный контент
|
# Одинаковое имя, разный контент × 3
|
||||||
shutil.copy(os.path.join(valid_dir, files[1]), os.path.join(out, files[0]))
|
for i in range(min(3, len(files)-1)):
|
||||||
MANIFEST[files[0]] = {"type": "duplicate", "original": files[0], "kind": "name_collision"}
|
shutil.copy(os.path.join(valid_dir, files[i+3]), os.path.join(out, files[i]))
|
||||||
|
MANIFEST[files[i]] = {"type": "duplicate", "original": files[i], "kind": "name_collision"}
|
||||||
|
|
||||||
print(f" ✓ Дубликаты: 2 шт.")
|
# Рассинхрон: имя файла ≠ номер в тексте
|
||||||
|
if len(files) >= 2:
|
||||||
|
src = os.path.join(valid_dir, files[0])
|
||||||
|
dst = os.path.join(out, "mismatch_" + files[1])
|
||||||
|
shutil.copy(src, dst)
|
||||||
|
MANIFEST[os.path.basename(dst)] = {"type": "error", "error": "name_content_mismatch", "status": "expected_classify_different"}
|
||||||
|
shutil.copy(os.path.join(valid_dir, files[1]), os.path.join(out, "mismatch_" + files[0]))
|
||||||
|
MANIFEST[os.path.basename("mismatch_" + files[0])] = {"type": "error", "error": "name_content_mismatch", "status": "expected_classify_different"}
|
||||||
|
|
||||||
|
print(f" ✓ Дубликаты + рассинхрон: 8 шт.")
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
@@ -459,6 +543,31 @@ def main():
|
|||||||
with open(manifest_path, 'w', encoding='utf-8') as f:
|
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(MANIFEST, f, ensure_ascii=False, indent=2)
|
json.dump(MANIFEST, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# Сгенерировать human-readable README.md
|
||||||
|
readme_path = os.path.join(OUT, "README.md")
|
||||||
|
lines = ["# Тестовые файлы — описание\n"]
|
||||||
|
cats = {"contract": "📄 Договоры", "spec": "📋 Спецификации", "addendum": "📎 Допсоглашения",
|
||||||
|
"error": "❌ Ошибочные", "trash": "🗑 Мусор", "zip": "📦 ZIP-архивы", "duplicate": "📋 Дубликаты"}
|
||||||
|
by_cat = {}
|
||||||
|
for fname, info in MANIFEST.items():
|
||||||
|
cat = info.get("type", "?")
|
||||||
|
by_cat.setdefault(cat, []).append((fname, info))
|
||||||
|
for cat, title in cats.items():
|
||||||
|
items = by_cat.get(cat, [])
|
||||||
|
if not items: continue
|
||||||
|
lines.append(f"\n## {title} ({len(items)} шт.)\n")
|
||||||
|
for fname, info in sorted(items):
|
||||||
|
company = info.get("company", "")
|
||||||
|
number = info.get("number", "")
|
||||||
|
ver = f" v{info['version']}" if info.get("version") else ""
|
||||||
|
err = info.get("error", "")
|
||||||
|
label = info.get("label", "")
|
||||||
|
extra = f" — {err}" if err else (f" — {label}" if label else "")
|
||||||
|
lines.append(f"- `{fname}` {company} {number}{ver}{extra}")
|
||||||
|
lines.append(f"\n\nВсего: **{len(MANIFEST)}** файлов")
|
||||||
|
with open(readme_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write('\n'.join(lines))
|
||||||
|
|
||||||
total = len(MANIFEST)
|
total = len(MANIFEST)
|
||||||
print(f"\n{'=' * 60}")
|
print(f"\n{'=' * 60}")
|
||||||
print(f"Всего файлов в manifest.json: {total}")
|
print(f"Всего файлов в manifest.json: {total}")
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Тестовые файлы — 30 кейсов (Opus)
|
||||||
|
|
||||||
|
**Все документы — полноценные:** шапка, преамбула с реквизитами сторон, 2-4 раздела, таблицы услуг (8 строк), подписи.
|
||||||
|
|
||||||
|
## Блок A — Classify (кейсы 1–11)
|
||||||
|
Проверка классификации: тип документа, номер, контрагент, дата.
|
||||||
|
|
||||||
|
| Файл | Кейс | Контрагент |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `договор-XXX001-03700.docx` | 1 | ЗАО "ТехноПлюс" |
|
||||||
|
| `договор-МЭС.docx` | 2 | АО "МосЭнергоСбыт" |
|
||||||
|
| `договор-нестандарт.docx` | 3 | ЗАО "Клиент-Сервис" |
|
||||||
|
| `допник-XXX003-01300.docx` | 4 | — (допник) |
|
||||||
|
| `спецификация-XXX001-03700.docx` | 5 | — (спецификация) |
|
||||||
|
| `договор-без-стороны.docx` | 6 | **null** (без контрагента) |
|
||||||
|
| `договор-дата-прописью.docx` | 7 | ООО "Ресурс-ЦОД" |
|
||||||
|
| `договор-две-даты.docx` | 8 | АО "ТехноДата" |
|
||||||
|
| `договор-длинная-преамбула.docx` | 9 | ООО "Поздний Контрагент" |
|
||||||
|
| `договор-много-номеров.docx` | 10 | ЗАО "МультиНомер" |
|
||||||
|
| `акт-сверки.docx` | 11 | **other** (мусор) |
|
||||||
|
|
||||||
|
## Блок B — Group (кейсы 12–20)
|
||||||
|
Проверка группировки: нормализация номеров, коллизии, семьи.
|
||||||
|
|
||||||
|
| Файлы | Кейс | Суть |
|
||||||
|
|-------|------|------|
|
||||||
|
| `договор-МЭС-норм` + `допник-МЭС-норм` | 12 | Разделители: `-` vs `/` vs пробел |
|
||||||
|
| `договор-03700` + `допник-О3700` | 13 | Кириллическая О vs цифра 0 |
|
||||||
|
| `договор-CBC` + `допник-СВС` | 14 | Латинская C vs кириллическая С |
|
||||||
|
| `договор-ЁЖ` + `допник-ЁЖ` | 15 | Буква Ё |
|
||||||
|
| `допник-без-родителя` | 16 | Допник без parent → unresolved |
|
||||||
|
| `договор-GR50` + `допник-GR50` | 17 | Допник через own_number |
|
||||||
|
| `договор-AB12` + `A-B12` + `допник-AB12` | 18 | Коллизия якорей |
|
||||||
|
| `договор-FAM` + `спека-FAM` + `допник-1/2-FAM` | 19 | Семья из 4 документов |
|
||||||
|
| `допник-1-XXX003-01300_2` | 20 | Суффикс в имени файла |
|
||||||
|
|
||||||
|
## Блок C — Compare (кейсы 21–30)
|
||||||
|
Проверка сравнения спецификаций. **8 строк услуг, изменена только v2.**
|
||||||
|
|
||||||
|
| Кейс | Суть | v2 отличия от v1 |
|
||||||
|
|------|------|-----------------|
|
||||||
|
| 21 | Цена +1 копейка | UPDATE — строка 1 (45 000,00 → 45 000,01) |
|
||||||
|
| 22 | Удаление услуги | DELETE — строка 3 (IP-адреса) |
|
||||||
|
| 23 | Переименование + цена | UPDATE — строка 5 (название + цена + дата) |
|
||||||
|
| 24 | Полная замена | FULL REPLACE — 3 новые строки |
|
||||||
|
| 25 | ADD | ADD — 2 строки (9, 10) |
|
||||||
|
| 26 | DELETE | DELETE — 2 строки (7, 8) |
|
||||||
|
| 27 | Только объём | UPDATE — строка 3 (8→12 IP) |
|
||||||
|
| 28 | Услуга-призрак | ADD — строка 9 (VPN, нет в v1) |
|
||||||
|
| 29 | Числа с пробелами | UPDATE — строка 1 (45 000→45 500) |
|
||||||
|
| 30 | Робастность парсера | UPDATE — строка 1 (45 000→51 000) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Всего: **50 файлов** в трёх папках (`block_a/`, `block_b/`, `block_c/`).
|
||||||
|
Сгенерированы: `testgen/gen_opus.py`.
|
||||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,320 @@
|
|||||||
|
{
|
||||||
|
"договор-XXX001-03700.docx": {
|
||||||
|
"case": 1,
|
||||||
|
"type": "contract",
|
||||||
|
"own_number": "XXX001-03700",
|
||||||
|
"counterparty": "ЗАО \"ТехноПлюс\""
|
||||||
|
},
|
||||||
|
"спецификация-XXX001-03700_v1.docx": {
|
||||||
|
"case": 1,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "XXX001-03700"
|
||||||
|
},
|
||||||
|
"договор-МЭС.docx": {
|
||||||
|
"case": 2,
|
||||||
|
"type": "contract",
|
||||||
|
"own_number": "МЭС-123/2024",
|
||||||
|
"counterparty": "АО \"МосЭнергоСбыт\""
|
||||||
|
},
|
||||||
|
"спецификация-МЭС.docx": {
|
||||||
|
"case": 2,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "МЭС-123/2024"
|
||||||
|
},
|
||||||
|
"договор-нестандарт.docx": {
|
||||||
|
"case": 3,
|
||||||
|
"type": "contract",
|
||||||
|
"own_number": "SVC-77",
|
||||||
|
"counterparty": "ЗАО \"Клиент-Сервис\""
|
||||||
|
},
|
||||||
|
"спецификация-SVC-77.docx": {
|
||||||
|
"case": 3,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "SVC-77"
|
||||||
|
},
|
||||||
|
"допник-XXX003-01300.docx": {
|
||||||
|
"case": 4,
|
||||||
|
"type": "supplement",
|
||||||
|
"own_number": "1",
|
||||||
|
"parent_number": "XXX003-01300"
|
||||||
|
},
|
||||||
|
"договор-без-стороны.docx": {
|
||||||
|
"case": 6,
|
||||||
|
"type": "contract",
|
||||||
|
"counterparty": null
|
||||||
|
},
|
||||||
|
"спецификация-NC-09.docx": {
|
||||||
|
"case": 6,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "NC-09"
|
||||||
|
},
|
||||||
|
"договор-дата-прописью.docx": {
|
||||||
|
"case": 7,
|
||||||
|
"type": "contract",
|
||||||
|
"doc_date": "2025-03-15",
|
||||||
|
"counterparty": "ООО \"Ресурс-ЦОД\""
|
||||||
|
},
|
||||||
|
"спецификация-DT-15.docx": {
|
||||||
|
"case": 7,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "DT-15"
|
||||||
|
},
|
||||||
|
"договор-две-даты.docx": {
|
||||||
|
"case": 8,
|
||||||
|
"type": "contract",
|
||||||
|
"doc_date": "2025-04-01",
|
||||||
|
"counterparty": "АО \"ТехноДата\""
|
||||||
|
},
|
||||||
|
"спецификация-TD-21.docx": {
|
||||||
|
"case": 8,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "TD-21"
|
||||||
|
},
|
||||||
|
"договор-длинная-преамбула.docx": {
|
||||||
|
"case": 9,
|
||||||
|
"type": "contract",
|
||||||
|
"own_number": "LATE-99",
|
||||||
|
"counterparty": "ООО \"Поздний Контрагент\""
|
||||||
|
},
|
||||||
|
"спецификация-LATE-99.docx": {
|
||||||
|
"case": 9,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "LATE-99"
|
||||||
|
},
|
||||||
|
"договор-много-номеров.docx": {
|
||||||
|
"case": 10,
|
||||||
|
"type": "contract",
|
||||||
|
"own_number": "MN-01",
|
||||||
|
"counterparty": "ЗАО \"МультиНомер\""
|
||||||
|
},
|
||||||
|
"спецификация-MN-01.docx": {
|
||||||
|
"case": 10,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "MN-01"
|
||||||
|
},
|
||||||
|
"акт-сверки.docx": {
|
||||||
|
"case": 11,
|
||||||
|
"type": "other"
|
||||||
|
},
|
||||||
|
"договор-МЭС-норм.docx": {
|
||||||
|
"case": 12,
|
||||||
|
"own": "МЭС-123/2024",
|
||||||
|
"counterparty": "АО \"МосЭнергоСбыт\""
|
||||||
|
},
|
||||||
|
"спецификация-МЭС-норм.docx": {
|
||||||
|
"case": 12,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "МЭС-123/2024"
|
||||||
|
},
|
||||||
|
"допник-МЭС-норм.docx": {
|
||||||
|
"case": 12,
|
||||||
|
"parent": "МЭС 123/2024"
|
||||||
|
},
|
||||||
|
"договор-03700.docx": {
|
||||||
|
"case": 13,
|
||||||
|
"own": "XXX001-03700",
|
||||||
|
"counterparty": "ЗАО \"ТехноПлюс\""
|
||||||
|
},
|
||||||
|
"спецификация-03700.docx": {
|
||||||
|
"case": 13,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "XXX001-03700"
|
||||||
|
},
|
||||||
|
"допник-О3700.docx": {
|
||||||
|
"case": 13,
|
||||||
|
"parent": "XXX001-О3700"
|
||||||
|
},
|
||||||
|
"договор-CBC.docx": {
|
||||||
|
"case": 14,
|
||||||
|
"own": "CBC-10",
|
||||||
|
"counterparty": "ООО \"СиБиСи\""
|
||||||
|
},
|
||||||
|
"спецификация-CBC.docx": {
|
||||||
|
"case": 14,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "CBC-10"
|
||||||
|
},
|
||||||
|
"допник-СВС.docx": {
|
||||||
|
"case": 14,
|
||||||
|
"parent": "СВС-10"
|
||||||
|
},
|
||||||
|
"договор-ЁЖ.docx": {
|
||||||
|
"case": 15,
|
||||||
|
"own": "ЁЖ-5",
|
||||||
|
"counterparty": "ООО \"Ёжик-Телеком\""
|
||||||
|
},
|
||||||
|
"спецификация-ЁЖ.docx": {
|
||||||
|
"case": 15,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "ЁЖ-5"
|
||||||
|
},
|
||||||
|
"допник-ЁЖ.docx": {
|
||||||
|
"case": 15,
|
||||||
|
"parent": "ЁЖ-5"
|
||||||
|
},
|
||||||
|
"допник-без-родителя.docx": {
|
||||||
|
"case": 16,
|
||||||
|
"type": "supplement",
|
||||||
|
"parent": null
|
||||||
|
},
|
||||||
|
"договор-GR50.docx": {
|
||||||
|
"case": 17,
|
||||||
|
"own": "GR-50",
|
||||||
|
"counterparty": "ООО \"ГринРайт\""
|
||||||
|
},
|
||||||
|
"спецификация-GR50.docx": {
|
||||||
|
"case": 17,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "GR-50"
|
||||||
|
},
|
||||||
|
"допник-GR50.docx": {
|
||||||
|
"case": 17,
|
||||||
|
"own": "GR-50"
|
||||||
|
},
|
||||||
|
"договор-AB12.docx": {
|
||||||
|
"case": 18,
|
||||||
|
"own": "AB-12",
|
||||||
|
"counterparty": "ООО \"Альфа-Бета\""
|
||||||
|
},
|
||||||
|
"спецификация-AB12.docx": {
|
||||||
|
"case": 18,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "AB-12"
|
||||||
|
},
|
||||||
|
"договор-A-B12.docx": {
|
||||||
|
"case": 18,
|
||||||
|
"own": "A-B12",
|
||||||
|
"counterparty": "ООО \"А-Бет\""
|
||||||
|
},
|
||||||
|
"спецификация-A-B12.docx": {
|
||||||
|
"case": 18,
|
||||||
|
"type": "specification",
|
||||||
|
"parent_number": "A-B12"
|
||||||
|
},
|
||||||
|
"допник-AB12.docx": {
|
||||||
|
"case": 18,
|
||||||
|
"parent": "AB12"
|
||||||
|
},
|
||||||
|
"договор-FAM.docx": {
|
||||||
|
"case": 19,
|
||||||
|
"date": "2025-01-10",
|
||||||
|
"counterparty": "ЗАО \"Фэмили-Групп\""
|
||||||
|
},
|
||||||
|
"спека-FAM.docx": {
|
||||||
|
"case": 19,
|
||||||
|
"date": "2025-02-01"
|
||||||
|
},
|
||||||
|
"допник-1-FAM.docx": {
|
||||||
|
"case": 19,
|
||||||
|
"date": "2025-03-01"
|
||||||
|
},
|
||||||
|
"допник-2-FAM.docx": {
|
||||||
|
"case": 19,
|
||||||
|
"date": "2025-05-01"
|
||||||
|
},
|
||||||
|
"допник-1-XXX003-01300_2.docx": {
|
||||||
|
"case": 20,
|
||||||
|
"own": "1",
|
||||||
|
"parent": "XXX003-01300"
|
||||||
|
},
|
||||||
|
"спека-цена-v1.docx": {
|
||||||
|
"case": 21,
|
||||||
|
"version": 1,
|
||||||
|
"parent_number": "CMP-021"
|
||||||
|
},
|
||||||
|
"допник-цена.docx": {
|
||||||
|
"case": 21,
|
||||||
|
"type": "supplement",
|
||||||
|
"parent_number": "CMP-021"
|
||||||
|
},
|
||||||
|
"спека-обнуление-v1.docx": {
|
||||||
|
"case": 22,
|
||||||
|
"version": 1,
|
||||||
|
"parent_number": "CMP-022"
|
||||||
|
},
|
||||||
|
"допник-обнуление.docx": {
|
||||||
|
"case": 22,
|
||||||
|
"type": "supplement",
|
||||||
|
"parent_number": "CMP-022"
|
||||||
|
},
|
||||||
|
"спека-переименование-v1.docx": {
|
||||||
|
"case": 23,
|
||||||
|
"version": 1,
|
||||||
|
"parent_number": "CMP-023"
|
||||||
|
},
|
||||||
|
"допник-переименование.docx": {
|
||||||
|
"case": 23,
|
||||||
|
"type": "supplement",
|
||||||
|
"parent_number": "CMP-023"
|
||||||
|
},
|
||||||
|
"спека-полная-v1.docx": {
|
||||||
|
"case": 24,
|
||||||
|
"version": 1,
|
||||||
|
"parent_number": "CMP-024"
|
||||||
|
},
|
||||||
|
"допник-новая-редакция.docx": {
|
||||||
|
"case": 24,
|
||||||
|
"type": "supplement",
|
||||||
|
"parent_number": "CMP-024"
|
||||||
|
},
|
||||||
|
"спека-добавление-v1.docx": {
|
||||||
|
"case": 25,
|
||||||
|
"version": 1,
|
||||||
|
"parent_number": "CMP-025"
|
||||||
|
},
|
||||||
|
"допник-добавление.docx": {
|
||||||
|
"case": 25,
|
||||||
|
"type": "supplement",
|
||||||
|
"parent_number": "CMP-025"
|
||||||
|
},
|
||||||
|
"спека-удаление-v1.docx": {
|
||||||
|
"case": 26,
|
||||||
|
"version": 1,
|
||||||
|
"parent_number": "CMP-026"
|
||||||
|
},
|
||||||
|
"допник-удаление.docx": {
|
||||||
|
"case": 26,
|
||||||
|
"type": "supplement",
|
||||||
|
"parent_number": "CMP-026"
|
||||||
|
},
|
||||||
|
"спека-объём-v1.docx": {
|
||||||
|
"case": 27,
|
||||||
|
"version": 1,
|
||||||
|
"parent_number": "CMP-027"
|
||||||
|
},
|
||||||
|
"допник-объём.docx": {
|
||||||
|
"case": 27,
|
||||||
|
"type": "supplement",
|
||||||
|
"parent_number": "CMP-027"
|
||||||
|
},
|
||||||
|
"спека-призрак-v1.docx": {
|
||||||
|
"case": 28,
|
||||||
|
"version": 1,
|
||||||
|
"parent_number": "CMP-028"
|
||||||
|
},
|
||||||
|
"допник-призрак.docx": {
|
||||||
|
"case": 28,
|
||||||
|
"type": "supplement",
|
||||||
|
"parent_number": "CMP-028"
|
||||||
|
},
|
||||||
|
"спека-формат-v1.docx": {
|
||||||
|
"case": 29,
|
||||||
|
"version": 1,
|
||||||
|
"parent_number": "CMP-029"
|
||||||
|
},
|
||||||
|
"допник-формат-чисел.docx": {
|
||||||
|
"case": 29,
|
||||||
|
"type": "supplement",
|
||||||
|
"parent_number": "CMP-029"
|
||||||
|
},
|
||||||
|
"спека-роб-v1.docx": {
|
||||||
|
"case": 30,
|
||||||
|
"version": 1,
|
||||||
|
"parent_number": "CMP-030"
|
||||||
|
},
|
||||||
|
"допник-роб.docx": {
|
||||||
|
"case": 30,
|
||||||
|
"type": "supplement",
|
||||||
|
"parent_number": "CMP-030"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
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