39 KiB
Архитектурное исследование: Сверка договоров v2
Дата: 27.06.2026 | Для: DeepSeek V4 Pro | По заказу: Владимир Крупский
Блок 1: Общая архитектура
1.1 Архитектура «с нуля»
Вот как бы я построил систему, зная все требования сейчас:
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
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_jsonclassify_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 Узкие места и масштабирование
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 |
Предлагаемые улучшения:
-
Перенос парсинга на ВМ (убрать зависимость от 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 → ВМ
-
Асинхронная очередь классификации:
Upload → Parse → [положить в очередь] → сразу вернуть «файлы загружены» → background: classify → groupЗаказчик не ждёт 125 секунд. Видит прогресс-бар через
/api/batch-progress. -
Параллельное сравнение групп:
# Сейчас: последовательно по всем 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 (минимальные изменения):
-- Добавляем поле для очереди
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):
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, завтра — другая система.
Решение: Абстрактный интерфейс + адаптеры.
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:
@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 (пример):
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 (от быстрого к точному):
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 токенов):
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):
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:
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» в договоре
Стратегия:
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:
- При matching по имени — нормализовать оба названия (убрать
-m) - Если match нашёлся → отметить флагом
payg: true - Для PAYG-услуг не сравнивать суммы (они переменные), сравнивать только факт наличия услуги и единицу измерения
- Для PAYG-услуг
date_startособенно важен — PAYG тарифицируется с даты начала
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) - Редактор промптов с историей версий
- Активный промпт выбирается из БД, не хардкод
Что предлагаю добавить:
# 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 в БД:
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 Цикл обратной связи
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
Конкретная реализация:
-- Таблица коррекций эксперта
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()
);
Как это использовать:
-
Быстрый цикл (часы): эксперт исправил → сохранили в
expert_corrections→ аналитика показывает «5 из 10 ошибок — даты» → правим промпт (добавляем акцент на даты) → активируем новую версию → лучше. -
Few-shot обучение (дни): накопили 20+ коррекций одного типа → добавляем в промпт как few-shot примеры:
ПРИМЕРЫ ОШИБОК (НЕ ПОВТОРЯЙ): ❌ Было: "Аренда стойко-места" с date_start: null ✅ Верно: "Аренда стойко-места" с date_start: "2025-01-01" (дата в преамбуле договора) -
A/B тестирование промптов (недели): запускаем старый и новый промпт на одном документе → сравниваем результаты → выбираем лучший.
Ключевое: не пытаемся «обучить модель» (это не наша модель, gpt-oss-120b — API). Вместо этого:
- Улучшаем промпты
- Добавляем few-shot примеры
- Меняем подход к извлечению
- Фиксируем все решения в БД — чтобы через месяц понять что работало, а что нет
Итоговая архитектура (сводка)
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 часа):
pg_dump --schema-onlyс продакшена →schema.sql- Создать новую БД
contracts_flaskна ВМ, применитьschema.sql - DNS
check.kube5s.ru→ уже есть ✅ - Пустой репо
contracts-flask→ уже есть ✅ 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 на ВМ.