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

39 KiB
Raw Blame History

Архитектурное исследование: Сверка договоров 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_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 Узкие места и масштабирование

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. Параллельное сравнение групп:

    # Сейчас: последовательно по всем 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:

  1. При matching по имени — нормализовать оба названия (убрать -m)
  2. Если match нашёлся → отметить флагом payg: true
  3. Для PAYG-услуг не сравнивать суммы (они переменные), сравнивать только факт наличия услуги и единицу измерения
  4. Для 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()
);

Как это использовать:

  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 примеры
  • Меняем подход к извлечению
  • Фиксируем все решения в БД — чтобы через месяц понять что работало, а что нет

Итоговая архитектура (сводка)

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 на ВМ.