# Архитектурное исследование: Сверка договоров v2 **Дата:** 27.06.2026 | **Для:** DeepSeek V4 Pro | **По заказу:** Владимир Крупский --- ## Блок 1: Общая архитектура ### 1.1 Архитектура «с нуля» Вот как бы я построил систему, зная все требования сейчас: ```mermaid graph TB subgraph "Ввод" A[Файловая шара
/облачный диск] end subgraph "Pre-processing Pipeline" B["① Фильтр мусора
━━━━━━━━━━━━━
Детерминированная
(ключевые слова + regex
по первым 2KB текста)"] C["② Парсинг документов
━━━━━━━━━━━━━
Python (pdfplumber + python-docx)
→ elements_json"] D["③ Классификация
━━━━━━━━━━━━━
LLM (лёгкая модель)
+ _smart_extract
→ тип/номер/дата/контрагент"] E["④ Группировка
━━━━━━━━━━━━━
Детерминированная Python
нормализация номеров + matching"] end subgraph "Core Processing" F["⑤ Извлечение спецификации
━━━━━━━━━━━━━
LLM (основная модель)
контекст: полный текст
договора/спецификации
→ ADD ops"] G["⑥ Сравнение ДС
━━━━━━━━━━━━━
LLM + Event Sourcing
контекст: текущая spec
+ текст ДС
→ ADD/UPDATE/DELETE/UNRESOLVED"] end subgraph "Сверка" H["⑦ Matching CRM ↔ Фискальная
━━━━━━━━━━━━━
Гибрид: хеш-матчинг по
нормализованному имени
+ LLM для несовпадений"] I["⑧ Отчёт о расхождениях
━━━━━━━━━━━━━
diff-представление
подсветка: даты, цены, суммы"] end subgraph "Хранилище" J[(PostgreSQL
Документы, Спецификации,
События, Промпты)] K[(Доп. хранилище
CRM-выгрузки
agnostic schema)] end subgraph "Обратная связь" L[Ручная коррекция
экспертом] M[Версионирование
исправленных промптов] end A --> B --> C --> D --> E --> F --> G G --> H --> I J --- F J --- G K --- H I --> L --> M M -.-> F M -.-> G style B fill:#e8f5e9 style E fill:#e8f5e9 style D fill:#fff3e0 style F fill:#fff3e0 style G fill:#fff3e0 style H fill:#e3f2fd ``` **Зоны ответственности:** | Компонент | Где LLM | Где детерминированная логика | |---|---|---| | ① Фильтр мусора | ❌ НЕТ | Ключевые слова + regex по заголовкам (быстро, 0 токенов) | | ② Парсинг | ❌ НЕТ | pdfplumber / python-docx → `elements_json` | | ③ Классификация | ✅ ЛЁГКАЯ LLM | `_smart_extract()` выжимка, `_safe_json_parse()` | | ④ Группировка | ❌ НЕТ | `normalize_number()` + matching по parent_number | | ⑤ Извлечение | ✅ ОСНОВНАЯ LLM | Сборка промпта (`build_prompt`), `_elements_to_text()` | | ⑥ Сравнение | ✅ ОСНОВНАЯ LLM | Event Sourcing (apply ops), `_upsert_spec_current()` | | ⑦ Matching | ✅ LLM для несовпадений | Хеш-матчинг по нормализованному имени для очевидных | | ⑧ Отчёт | ❌ НЕТ | Чистый diff, группировка расхождений по типам | **Ключевой принцип:** LLM — только там, где нужна семантика. Всё остальное — быстрый детерминированный код. Это даёт: - Предсказуемость (детерминированное не ломается при смене модели) - Экономию токенов (LLM — дорого и медленно) - Отлаживаемость (можно тестировать unit-тестами без LLM) --- ### 1.2 Agent-based vs Pipeline ```mermaid graph LR subgraph "Pipeline (текущий)" P1[Upload] --> P2[Parse] --> P3[Classify] --> P4[Group] --> P5[Compare] end subgraph "Agent-based (предлагаемый гибрид)" O[Orchestrator Agent] O --> W1[Parse Worker] O --> W2[Classify Worker] O --> W3[Compare Worker] O --> W4[Match Worker] O --> T[Tools: DB, LLM, FileSystem] end ``` | Критерий | Pipeline | Agent-based | |---|---|---| | **Плюсы** | Предсказуемый порядок, легче отлаживать, меньше токенов, детерминированные шаги не требуют LLM | Гибкость: оркестратор решает **что делать** на основе промежуточных результатов. Может перепланировать при ошибках | | **Минусы** | Жёсткая последовательность. Если шаг упал — либо пропускаем, либо всё стоп. Трудно адаптировать под неожиданные форматы документов | Дороже (каждый шаг оркестратора — LLM-вызов). Сложнее отлаживать. Риск «галлюцинаций» оркестратора | | **Когда** | Когда формат входа **известен**, pipeline стабилен | Когда формат входа **неизвестен**, нужно адаптивное поведение | **Мой вердикт: ГИБРИДНЫЙ подход.** ``` Orchestrator (лёгкая LLM, ~500 токенов/вызов) │ ├── «Это договор?» → Фильтр мусора (детерминирован) ├── «Какой тип?» → Classify worker (LLM, уже есть) ├── «С чем группировать?» → Grouping (детерминирован) ├── «Извлечь спецификацию?» → Extract worker (LLM) └── «Сравнить с CRM?» → Match worker (LLM + хеши) ``` **Почему не pure agents:** - 100+ файлов × 500 токенов оркестратора = 50K+ токенов только на планирование - Для **стандартных** договоров ЦОД pipeline предсказуем — хватит 95% случаев - Оркестратор нужен только для **краевых случаев**: нестандартный формат, ошибка парсинга, конфликт при группировке **Инструменты (tools) для агента:** - `parse_document(file_id)` → elements_json - `classify_document(file_id)` → {type, number, date, counterparty} - `extract_spec(contract_id)` → [spec_rows] - `compare_supplement(supp_id, current_spec)` → [ops] - `match_crm_row(spec_row)` → {crm_match, confidence} - `query_db(sql)` → rows (read-only) --- ### 1.3 RAG — нужен ли? **Кратко: для текущей задачи RAG НЕ НУЖЕН. Хватит контекстного окна.** Обоснование: | Что | Почему не RAG | |---|---| | **Текст одного допника** | 5-50 KB → влезает в контекстное окно gpt-oss-120b (8K токенов ≈ ~24KB текста) | | **Текущая спецификация** | 10-50 строк × ~200 симв = 10KB → тоже влезает | | **Сравнение договоров** | Не semantic search. Нужно **точное** сопоставление строк, а не «похожие документы» | **Когда RAG стал бы нужен (v3+):** - Если бы нужно было искать **похожие прецеденты** в истории (как раньше решали похожие расхождения) - Если бы был корпус из 10 000+ договоров и нужно было искать «как обычно формулируют услугу X» - Для чата: «покажи все договоры где цена стойко-места > 50 000» **Что где хранить:** | Хранилище | Что | |---|---| | **PostgreSQL (реляционная)** | Документы, `elements_json`, `spec_current`, `spec_events`, контракты, промпты, CRM-выгрузки | | **Файловая система** | Исходные .docx/.pdf (для перепарсивания при смене парсера) | | **Векторная БД** (пока НЕ нужно) | Эмбеддинги названий услуг для семантического matching (альтернатива LLM-matching) | **Но:** если модель сменится на что-то с окном 128K+ токенов (Claude, GPT-4o, Gemini), можно будет отправлять **весь договор целиком** + текущую спецификацию. Тогда `_smart_extract` станет не нужен — LLM сама найдёт нужные строки. --- ## Блок 2: Обработка 100+ файлов ### 2.1 Узкие места и масштабирование ```mermaid gantt title Время обработки 100 файлов (текущий pipeline) dateFormat X axisFormat %s section Фильтр мусора 100 файлов × 10ms :0, 1 section Парсинг 100 файлов × 500ms :1, 50 section Классификация 100 файлов × 2-10s :50, 300 section Группировка 1 вызов × 100ms :300, 300 section Сравнение (LLM) 30 допников × 30s :300, 900 ``` **Главное узкое место — КЛАССИФИКАЦИЯ (50-300s для 100 файлов при 4 воркерах).** Текущий `ThreadPoolExecutor(max_workers=4)` + `classify_worker.py` subprocess — уже правильное решение. Но для 100+ файлов: **Что ещё станет узким местом:** | Узкое место | Почему | Решение | |---|---|---| | **Классификация** | 100 файлов × 5s / 4 воркера = 125s | Увеличить `MAX_WORKERS` до 8-10 (но риск троттлинга api.aillm.ru) | | **Сравнение ДС** | 30 допников × 30s последовательно = 900s (15 мин!) | Параллельное сравнение **независимых** групп (разные contract_id — нет гонки) | | **Парсинг docx/pdf** | Java POI через Lucee — медленно для 100 файлов | Перенести парсинг на ВМ (pdfplumber/python-docx, см. ниже) | | **Память процесса** | 100 `elements_json` в памяти БД | Ок — в БД, не в памяти питона | | **api.aillm.ru rate limit** | Бесплатный эндпоинт, неизвестный лимит | Семафор + exponential backoff | **Предлагаемые улучшения:** 1. **Перенос парсинга на ВМ** (убрать зависимость от Lucee/Java): ``` СЕЙЧАС: JS → convert_server → Lucee parser.cfm (Java POI/PDFBox) → обратно на ВМ ПРЕДЛОЖЕНИЕ: JS → convert_server → services/parse.py (pdfplumber + python-docx) ``` - python-docx для .docx (чистый Python, без Java) - pdfplumber для .pdf (лучше PDFBox для таблиц) - Убирает latency сетевого вызова Lucee → ВМ 2. **Асинхронная очередь классификации:** ``` Upload → Parse → [положить в очередь] → сразу вернуть «файлы загружены» → background: classify → group ``` Заказчик не ждёт 125 секунд. Видит прогресс-бар через `/api/batch-progress`. 3. **Параллельное сравнение групп:** ```python # Сейчас: последовательно по всем supps for s in supps: # 30 допников × 30s = 900s compare(s) # Предложение: параллельно по НЕЗАВИСИМЫМ группам with ThreadPoolExecutor(max_workers=3) as pool: futures = {pool.submit(compare_group, g): g for g in independent_groups} ``` Группы с разными `contract_id` независимы — можно сравнивать параллельно. --- ### 2.2 Очередь (RabbitMQ/Redis/Kafka) или PostgreSQL? | Критерий | PostgreSQL (текущий) | Redis | RabbitMQ | |---|---|---|---| | **Простота** | ✅ Уже есть, не надо ставить | Средне | Средне | | **Надёжность** | ✅ ACID, не теряем задачи | ❌ Может потерять при перезапуске | ✅ Persistence | | **Мониторинг** | ✅ SELECT для просмотра очереди | Нужен redis-cli | Нужен management plugin | | **Производительность** | Средне (polling) | ✅ Высокая (pub/sub) | ✅ Высокая | | **Подходит для** | **До 1000 файлов/день** | До 10K/день | До 100K/день | **Вердикт: PostgreSQL ДОСТАТОЧНО для текущего масштаба.** Для 100+ файлов за раз, несколько раз в неделю — PostgreSQL-очередь через `documents.classify_status = 'pending'` + `classify_worker.py` subprocess — адекватное решение. Не надо усложнять. **Когда переходить на RabbitMQ/Redis:** - Если заказчик начнёт загружать 1000+ файлов **ежедневно** - Если появятся **несколько воркеров** на разных машинах - Если нужен **приоритет** (срочные договоры вне очереди) **Предлагаемая схема на PostgreSQL (минимальные изменения):** ```sql -- Добавляем поле для очереди ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_priority INT DEFAULT 0; ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_attempts INT DEFAULT 0; ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_next_attempt TIMESTAMPTZ; -- Воркер забирает задачи с ORDER BY priority, attempt, next_attempt SELECT * FROM documents WHERE classify_status = 'pending' AND (classify_next_attempt IS NULL OR classify_next_attempt <= NOW()) ORDER BY classify_priority DESC, classify_attempts ASC LIMIT 10 FOR UPDATE SKIP LOCKED; -- конкурентное потребление ``` --- ### 2.3 «Мусорная» фильтрация на раннем этапе **Это КРИТИЧЕСКИ важно для 100+ файлов.** Если 50% файлов — счета/акты/платёжки, а мы их парсим и классифицируем — тратим 50% ресурсов впустую. **Предлагаемый трёхэтапный фильтр:** ``` Этап 1: Фильтр по имени файла (0ms, детерминирован) ├── regex: (сч[её]т|акт|плат[её]ж|УПД|сверк|инвойс|invoice|act|payment) ├── сразу помечать doc_type='garbage', НЕ парсить └── точность: ~40% мусора Этап 2: Фильтр по первым 2KB текста (после парсинга, 10ms, детерминирован) ├── Ключевые слова в заголовке: │ «СЧЕТ-ФАКТУРА», «АКТ СВЕРКИ», «АКТ оказанных услуг», │ «ПЛАТЁЖНОЕ ПОРУЧЕНИЕ», «УПД», «СЧЕТ НА ОПЛАТУ» ├── Если нашли → помечать doc_type='garbage', НЕ классифицировать LLM └── точность: ~55% мусора (суммарно) Этап 3: LLM-классификация (оставшиеся, 2-10s) ├── Только для файлов, прошедших этапы 1-2 └── LLM определяет точный тип: contract/supplement/specification/other ``` **Реализация этапа 2 (в `services/classify.py` перед `_call_llm_classify`):** ```python GARBAGE_MARKERS = [ 'СЧЕТ-ФАКТУРА', 'СЧЕТ НА ОПЛАТУ', 'АКТ СВЕРКИ', 'АКТ ОКАЗАННЫХ УСЛУГ', 'АКТ ВЫПОЛНЕННЫХ РАБОТ', 'ПЛАТЁЖНОЕ ПОРУЧЕНИЕ', 'УНИВЕРСАЛЬНЫЙ ПЕРЕДАТОЧНЫЙ', 'УПД', 'ПЛАТЕЖНОЕ ПОРУЧЕНИЕ' ] def is_garbage_by_header(text): """Быстрая проверка — не гонять LLM на мусор.""" header = text[:2000].upper() for marker in GARBAGE_MARKERS: if marker in header: return True return False ``` **Экономия:** при 50% мусора в 100 файлах: вместо 100 LLM-вызовов (500s) → 50 LLM-вызовов (250s). **Экономия 50% времени и токенов.** --- ## Блок 3: Сравнение CRM ↔ фискальная система ### 3.1 Agnostic к источнику — проектирование модуля сравнения **Проблема:** мы не знаем формат CRM. Сегодня — одна CRM, завтра — другая система. **Решение: Абстрактный интерфейс + адаптеры.** ```mermaid graph TB subgraph "Источники данных" CRM1[CRM
(текущая)] CRM2[Другая система
(будущая)] FISC[Фискальная система
(из договоров)] end subgraph "Адаптеры (по одному на источник)" A1[CRM Adapter
нормализует поля
в канонический формат] A2[Future Adapter] end subgraph "Каноническая модель строки" CAN[CanonicalRow
────────────
service_name: str
article_code: str
price: Decimal
qty: Decimal
sum: Decimal
date_start: Date
date_end: Date
unit: str
source: 'crm' | 'fiscal'
source_id: str] end subgraph "Matcher (agnostic)" MATCH[RowMatcher
────────────
match_by_hash
match_by_name
match_by_llm
→ MatchResult] end CRM1 --> A1 --> CAN CRM2 --> A2 --> CAN FISC --> CAN CAN --> MATCH ``` **Каноническая модель `CanonicalRow`:** ```python @dataclass class CanonicalRow: """Строка спецификации в каноническом формате (source-agnostic).""" service_name: str # нормализованное название услуги article_code: str | None # артикул (если есть) price: Decimal | None qty: Decimal | None sum: Decimal | None date_start: date | None # КРИТИЧНОЕ ПОЛЕ date_end: date | None unit: str | None # кВт, шт., U, Мбит/с, ... source: str # 'crm' | 'fiscal' source_id: str # ссылка на оригинал (для аудита) @property def name_hash(self) -> str: """Нормализованный хеш названия для быстрого matching.""" return _hash(normalize_service_name(self.service_name)) ``` **Адаптер для CRM (пример):** ```python class CRMSourceAdapter: """Адаптер для конкретной CRM. Меняется только этот класс.""" def extract_rows(self, crm_export_path: str) -> list[CanonicalRow]: """Читает CSV/JSON/API CRM → список CanonicalRow.""" # Специфично для CRM заказчика df = pd.read_csv(crm_export_path, sep=';') rows = [] for _, r in df.iterrows(): rows.append(CanonicalRow( service_name=r['Наименование'], article_code=r.get('Артикул'), price=Decimal(str(r['Цена'])), qty=Decimal(str(r['Кол-во'])), sum=Decimal(str(r['Сумма'])), date_start=parse_date(r['Дата начала']), date_end=parse_date(r.get('Дата окончания')), unit=r.get('Ед. изм.'), source='crm', source_id=r['ID'], )) return rows ``` **Ключевое:** когда завтра появится другая система — пишем **только новый адаптер**. Matcher не меняется. --- ### 3.2 Matching строк: CRM ↔ фискальная система **Трёхуровневый matching (от быстрого к точному):** ```mermaid graph LR A[CRM строка] --> B{① Хеш-матчинг
по name_hash} B -->|Совпал| D[✓ MATCH (100% confidence)] B -->|Не совпал| C{② Семантический
по имени} C -->|Высокая confidence| E[✓ MATCH (80-95% confidence)] C -->|Низкая| F{③ LLM-матчинг} F --> G[✓ MATCH / ✗ NO MATCH
+ объяснение] ``` **① Хеш-матчинг (0ms, 0 токенов):** ```python def match_by_hash(crm_rows, fiscal_rows): """Точное совпадение по нормализованному имени.""" fiscal_by_hash = {r.name_hash: r for r in fiscal_rows} matched = [] unmatched = [] for crm_row in crm_rows: if crm_row.name_hash in fiscal_by_hash: matched.append((crm_row, fiscal_by_hash[crm_row.name_hash], 1.0)) else: unmatched.append(crm_row) return matched, unmatched ``` **② Семантический matching (Python, без LLM):** ```python def match_by_name_similarity(crm_row, fiscal_rows, threshold=0.8): """Fuzzy matching по названиям услуг.""" from difflib import SequenceMatcher best_score = 0 best_match = None for f_row in fiscal_rows: score = SequenceMatcher(None, crm_row.service_name.lower(), f_row.service_name.lower() ).ratio() if score > best_score: best_score = score best_match = f_row if best_score >= threshold: return best_match, best_score return None, 0 ``` **③ LLM-матчинг (для оставшихся ~10-20% сложных случаев):** ``` Промпт: «Вот строка из CRM: {crm_row} Вот строки из фискальной системы: {fiscal_rows} Найди соответствие или скажи что соответствия нет. Учитывай: синонимы ("аренда стойки" = "colocation"), объединение/разделение строк, PAYG-услуги без артикулов.» ``` **Почему не только LLM:** 100 строк × 500 токенов = 50K токенов только на matching. А ①+② обрабатывают 80% за 0 токенов. --- ### 3.3 Даты — критичный фокус **Почему даты — главный источник расхождений (со слов заказчика):** - CRM может иметь `date_start = 01.01.2025` - Фискальная система (договор) может иметь `date_start = 15.01.2025` (дата подписания акта приёмки, а не договора) - Разница в 14 дней → недоплата/переплата за 14 дней × стоимость услуги **Стратегия сравнения с акцентом на `date_start`:** ```python def compare_dates(crm_row, fiscal_row): """Сравнение дат — основной фокус.""" result = { 'matched': True, 'date_start_match': True, 'date_start_diff_days': 0, 'date_start_warning': None, 'price_match': True, 'sum_match': True, } # Сравнение дат if crm_row.date_start and fiscal_row.date_start: diff = (crm_row.date_start - fiscal_row.date_start).days result['date_start_diff_days'] = diff if diff != 0: result['date_start_match'] = False if abs(diff) <= 5: result['date_start_warning'] = 'minor' # возможно округление до месяца elif abs(diff) <= 31: result['date_start_warning'] = 'significant' # расхождение на месяц else: result['date_start_warning'] = 'critical' # серьёзное расхождение # Если даты не совпадают, но всё остальное совпадает (имя, цена, количество) if not result['date_start_match'] and result['price_match'] and result['sum_match']: result['likely_cause'] = 'date_input_error' # вероятно ошибка ввода даты return result ``` **Визуализация расхождений (диаграмма Ганта):** ``` Услуга | Янв | Фев | Март | Апр | ────────────────────┼───────┼───────┼───────┼───────| CRM: Стойка 10kW |████████████████| Фискал: Стойка 10kW | ████████████████| ^^^^— расхождение 15 дней ``` Это можно отрендерить как HTML/CSS бары — наглядно видны сдвиги дат. --- ### 3.4 PAYG (суффикс `-m`) — стратегия сопоставления **Проблема PAYG:** - PAYG-услуги (pay-as-you-go) — переменное потребление, нет фиксированной цены - В счетах нет кодов артикулов - Например: «IP-адрес IPv4-m» в CRM vs «IP-адрес IPv4» в договоре **Стратегия:** ```python def normalize_payg_name(name): """Убирает суффикс -m для сопоставления PAYG-услуг.""" import re # Убираем суффикс -m (с границей слова или концом строки) normalized = re.sub(r'-m(\s|$)', r'\1', name) # Примеры: # «IP-адрес IPv4-m» → «IP-адрес IPv4» # «Канал связи 100Мбит/с-m» → «Канал связи 100Мбит/с» return normalized ``` **Алгоритм для PAYG:** 1. При matching по имени — нормализовать **оба** названия (убрать `-m`) 2. Если match нашёлся → отметить флагом `payg: true` 3. Для PAYG-услуг **не сравнивать суммы** (они переменные), сравнивать только **факт наличия услуги** и **единицу измерения** 4. Для PAYG-услуг `date_start` **особенно важен** — PAYG тарифицируется с даты начала ```python def match_payg(crm_row, fiscal_rows): """Особая логика для PAYG.""" crm_name_normalized = normalize_payg_name(crm_row.service_name) for f_row in fiscal_rows: f_name_normalized = normalize_payg_name(f_row.service_name) if crm_name_normalized == f_name_normalized: return MatchResult( matched=True, payg=True, compare_sum=False, # суммы не сравниваем для PAYG compare_date_start=True, # даты КРИТИЧНЫ note=f'PAYG: {crm_row.service_name} ↔ {f_row.service_name}', ) return MatchResult(matched=False) ``` --- ## Блок 4: Итеративность и неопределённость ### 4.1 Менять промпты/модели/подходы без переписывания кода **Что уже есть (✅ хорошо):** - Промпты в БД с версионированием (`prompts` таблица + `prompt.cfm`/`db/prompts.py`) - Редактор промптов с историей версий - Активный промпт выбирается из БД, не хардкод **Что предлагаю добавить:** ```python # config.py — ЕДИНСТВЕННОЕ место для конфигурации LLM @dataclass class LLMConfig: """Меняется без правки кода — через БД или env.""" model: str = os.environ.get("LLM_MODEL", "gpt-oss-120b") url: str = os.environ.get("LLM_URL", "https://api.aillm.ru/v1/chat/completions") max_tokens: int = int(os.environ.get("LLM_MAX_TOKENS", "8000")) temperature: float = float(os.environ.get("LLM_TEMPERATURE", "0.1")) timeout: int = int(os.environ.get("LLM_TIMEOUT", "120")) # Разные модели для разных задач classify_model: str = os.environ.get("LLM_CLASSIFY_MODEL", model) # полегче extract_model: str = os.environ.get("LLM_EXTRACT_MODEL", model) # основная match_model: str = os.environ.get("LLM_MATCH_MODEL", model) # для сверки ``` **Принцип: всё что может поменяться — в БД или env. Код — только движок.** | Что меняется | Где менять | Без правки кода? | |---|---|---| | Промпт | БД `prompts` → activate | ✅ Да | | Модель LLM | env `LLM_MODEL` | ✅ Да | | Температура | env `LLM_TEMPERATURE` | ✅ Да | | URL API | env `LLM_URL` | ✅ Да | | Garbage-маркеры | `garbage_markers.json` в БД или файле | ✅ Да | | Стратегия matching | `matching_rules` в БД | ✅ Да (если сделать rules engine) | | Порядок pipeline | ❌ Пока хардкод | 🔧 Можно сделать DAG в БД | **Предложение: Pipeline as DAG в БД:** ```sql CREATE TABLE pipeline_steps ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, -- 'filter_garbage', 'parse', 'classify', 'extract', ... handler TEXT NOT NULL, -- 'services.classify:classify_batch' depends_on INT[] DEFAULT '{}', -- какие шаги должны быть завершены config JSONB DEFAULT '{}', -- параметры шага enabled BOOLEAN DEFAULT true, created_at TIMESTAMPTZ DEFAULT NOW() ); ``` Меняя записи в этой таблице, можно переставлять шаги или добавлять новые **без правки кода**. --- ### 4.2 MVP-границы: v1, v2, v3 ``` v1 (MVP) — БАЗОВАЯ ФУНКЦИЯ ├── ✅ Загрузка 100+ файлов (из локали/файловой шары) ├── ✅ Фильтр мусора (этапы 1-2, детерминированные) ├── ✅ Парсинг docx/pdf на ВМ (убрать зависимость от Lucee) ├── ✅ Классификация (LLM, параллельно) ├── ✅ Группировка по контрагентам ├── ✅ Извлечение спецификации (LLM) ├── ✅ Сравнение ДС (LLM + Event Sourcing) ├── ✅ Выгрузка результата (CSV/JSON) ├── ❌ БЕЗ сверки с CRM (только извлечение из договоров) ├── ❌ БЕЗ красивого UI (минимальный интерфейс для отладки) └── Домен: contracts.kube5s.ru v2 — СВЕРКА ├── ✅ Адаптер CRM (первый источник) ├── ✅ Matching CRM ↔ фискальная (трёхуровневый) ├── ✅ Отчёт о расхождениях (diff) ├── ✅ Подсветка дат ├── ✅ PAYG-обработка ├── ✅ Ручная коррекция экспертом └── Домен: check.kube5s.ru v3 — ЮЗАБЕЛЬНОСТЬ ├── ✅ Красивый UI (две панели, отчёт) ├── ✅ Векторная БД для семантического поиска ├── ✅ Чат (Q&A по всем договорам) ├── ✅ Автообучение на коррекциях (few-shot из истории) ├── ✅ Экспорт в Excel └── Домен: contracts.kube5s.ru (единый) ``` **Почему сверка с CRM — это v2, а не v1:** - Заказчик сам говорит: «сейчас и с подходом всё неясно» - Сначала надо доказать что LLM **вообще** может точно извлечь спецификацию из 100+ договоров - Потом, имея эталонные данные, строить сверку - Это снижает риск: не строим сложный matching для неподтверждённого качества извлечения --- ### 4.3 Цикл обратной связи ```mermaid graph TB A[LLM извлекает спецификацию] --> B[Результат показан эксперту] B --> C{Эксперт: верно?} C -->|✅ Да| D[Сохраняем как
положительный пример] C -->|❌ Нет| E[Эксперт исправляет] E --> F[Сохраняем пару
❌ было → ✅ стало] F --> G[Аналитика ошибок
какие типы ошибок частые?] G --> H{Можно исправить
промптом?} H -->|Да| I[Правим промпт
новая версия] H -->|Нет| J[Меняем подход
алгоритм / модель] I --> K[Few-shot примеры
в промпт] D --> K K --> A style C fill:#fff3e0 style G fill:#e3f2fd ``` **Конкретная реализация:** ```sql -- Таблица коррекций эксперта CREATE TABLE expert_corrections ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_id UUID REFERENCES spec_events(id), -- какое событие LLM corrected_values JSONB NOT NULL, -- что исправил эксперт correction_type TEXT, -- 'name_fix', 'date_fix', 'price_fix', 'missing_row', 'extra_row' expert_comment TEXT, used_in_prompt BOOLEAN DEFAULT false, -- включено в few-shot? created_at TIMESTAMPTZ DEFAULT NOW() ); ``` **Как это использовать:** 1. **Быстрый цикл (часы):** эксперт исправил → сохранили в `expert_corrections` → аналитика показывает «5 из 10 ошибок — даты» → правим промпт (добавляем акцент на даты) → активируем новую версию → лучше. 2. **Few-shot обучение (дни):** накопили 20+ коррекций одного типа → добавляем в промпт как few-shot примеры: ``` ПРИМЕРЫ ОШИБОК (НЕ ПОВТОРЯЙ): ❌ Было: "Аренда стойко-места" с date_start: null ✅ Верно: "Аренда стойко-места" с date_start: "2025-01-01" (дата в преамбуле договора) ``` 3. **A/B тестирование промптов (недели):** запускаем старый и новый промпт на одном документе → сравниваем результаты → выбираем лучший. **Ключевое:** не пытаемся «обучить модель» (это не наша модель, gpt-oss-120b — API). Вместо этого: - Улучшаем промпты - Добавляем few-shot примеры - Меняем подход к извлечению - **Фиксируем все решения в БД** — чтобы через месяц понять что работало, а что нет --- ## Итоговая архитектура (сводка) ```mermaid graph TB subgraph "v1: Извлечение (MVP)" U1[100+ файлов] --> F1[Фильтр мусора ⚡0ms] F1 --> P1[Парсинг Python ⚡500ms] P1 --> C1[Классификация LLM 🔥2-10s] C1 --> G1[Группировка Python ⚡100ms] G1 --> E1[Извлечение LLM 🔥30s] E1 --> S1[(spec_current)] end subgraph "v2: Сверка" CRM[CRM-выгрузка] --> AD[CRM Adapter] S1 --> MC[Matcher ⚡хеш → fuzzy → LLM] AD --> MC MC --> RPT[Отчёт о расхождениях] end subgraph "Обратная связь" RPT --> EXP[Эксперт] EXP --> CORR[expert_corrections] CORR --> PROMPT[Улучшение промптов] PROMPT -.-> E1 end style F1 fill:#e8f5e9 style G1 fill:#e8f5e9 style C1 fill:#fff3e0 style E1 fill:#fff3e0 style MC fill:#e3f2fd ``` --- ## Практические рекомендации для DeepSeek V4 Pro ### Что кодить СЕЙЧАС (Фаза 0: изоляция) Как описано в `opus-plan-review-2026-06-27.md`, план Opus из 6 фаз — правильный вектор. Но предлагаю **упростить Фазу 0**: **Ф0. Предпосылки (1-2 часа):** 1. `pg_dump --schema-only` с продакшена → `schema.sql` 2. Создать новую БД `contracts_flask` на ВМ, применить `schema.sql` 3. DNS `check.kube5s.ru` → уже есть ✅ 4. Пустой репо `contracts-flask` → уже есть ✅ 5. `LLM_KEY` → должен быть в `.env` на ВМ **Ф1. Бэкенд на ВМ (основная работа):** - `~/contracts-flask/` — копия `deploy/` (db/, services/, llm_prompt.py, convert_server.py) - ИСПРАВИТЬ `llm_prompt.py` — убрать зависимость от Lucee (`_fetch_prompt()` → `db.prompts.get_active()`) - ИСПРАВИТЬ `services/llm.py` — взять версию из репы (свежее) - Порт 8777, БД `contracts_flask`, systemd-юнит `contracts-flask.service` **Ф2-Ф3. Nginx + JS:** - Nginx: `check.kube5s.ru` → :8777 - JS: скопировать 6 файлов, поменять `VM_API='https://check.kube5s.ru'` **Ф4+ отложить** — морда на managed Flask не нужна для v1 MVP. Используем текущий index.cfm как есть, или минимальный HTML на ВМ.