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