# Архитектурное исследование: Сверка договоров v2
**Дата:** 27.06.2026 | **Для:** DeepSeek V4 Pro | **По заказу:** Владимир Крупский
---
## Блок 1: Общая архитектура
### 1.1 Архитектура «с нуля»
Вот как бы я построил систему, зная все требования сейчас:
```mermaid
graph TB
subgraph "Ввод"
A[Файловая шара
/облачный диск]
end
subgraph "Pre-processing Pipeline"
B["① Фильтр мусора
━━━━━━━━━━━━━
Детерминированная
(ключевые слова + regex
по первым 2KB текста)"]
C["② Парсинг документов
━━━━━━━━━━━━━
Python (pdfplumber + python-docx)
→ elements_json"]
D["③ Классификация
━━━━━━━━━━━━━
LLM (лёгкая модель)
+ _smart_extract
→ тип/номер/дата/контрагент"]
E["④ Группировка
━━━━━━━━━━━━━
Детерминированная Python
нормализация номеров + matching"]
end
subgraph "Core Processing"
F["⑤ Извлечение спецификации
━━━━━━━━━━━━━
LLM (основная модель)
контекст: полный текст
договора/спецификации
→ ADD ops"]
G["⑥ Сравнение ДС
━━━━━━━━━━━━━
LLM + Event Sourcing
контекст: текущая spec
+ текст ДС
→ ADD/UPDATE/DELETE/UNRESOLVED"]
end
subgraph "Сверка"
H["⑦ Matching CRM ↔ Фискальная
━━━━━━━━━━━━━
Гибрид: хеш-матчинг по
нормализованному имени
+ LLM для несовпадений"]
I["⑧ Отчёт о расхождениях
━━━━━━━━━━━━━
diff-представление
подсветка: даты, цены, суммы"]
end
subgraph "Хранилище"
J[(PostgreSQL
Документы, Спецификации,
События, Промпты)]
K[(Доп. хранилище
CRM-выгрузки
agnostic schema)]
end
subgraph "Обратная связь"
L[Ручная коррекция
экспертом]
M[Версионирование
исправленных промптов]
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
(текущая)]
CRM2[Другая система
(будущая)]
FISC[Фискальная система
(из договоров)]
end
subgraph "Адаптеры (по одному на источник)"
A1[CRM Adapter
нормализует поля
в канонический формат]
A2[Future Adapter]
end
subgraph "Каноническая модель строки"
CAN[CanonicalRow
────────────
service_name: str
article_code: str
price: Decimal
qty: Decimal
sum: Decimal
date_start: Date
date_end: Date
unit: str
source: 'crm' | 'fiscal'
source_id: str]
end
subgraph "Matcher (agnostic)"
MATCH[RowMatcher
────────────
match_by_hash
match_by_name
match_by_llm
→ 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{① Хеш-матчинг
по name_hash}
B -->|Совпал| D[✓ MATCH (100% confidence)]
B -->|Не совпал| C{② Семантический
по имени}
C -->|Высокая confidence| E[✓ MATCH (80-95% confidence)]
C -->|Низкая| F{③ LLM-матчинг}
F --> G[✓ MATCH / ✗ NO MATCH
+ объяснение]
```
**① Хеш-матчинг (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[Сохраняем как
положительный пример]
C -->|❌ Нет| E[Эксперт исправляет]
E --> F[Сохраняем пару
❌ было → ✅ стало]
F --> G[Аналитика ошибок
какие типы ошибок частые?]
G --> H{Можно исправить
промптом?}
H -->|Да| I[Правим промпт
новая версия]
H -->|Нет| J[Меняем подход
алгоритм / модель]
I --> K[Few-shot примеры
в промпт]
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 на ВМ.