Files
contracts/History/architecture-research-v2-2026-06-27.md
T

796 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектурное исследование: Сверка договоров 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 на ВМ.