796 lines
39 KiB
Markdown
796 lines
39 KiB
Markdown
# Архитектурное исследование: Сверка договоров 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 на ВМ.
|