# Сессия 12 — Архитектура Event Sourcing v2 Дата: 2026-06-20 ## Контекст Обсуждение новой архитектуры сравнения договоров: переход от pairwise diff по `row_num` к Event Sourcing с LLM-интерпретацией допсоглашений (ДС). Участники: DeepSeek (этот агент), Gemini, Sonnet. ## Ключевые решения ### 1. Разделение труда - **LLM** переводит текст ДС → список операций (ADD/UPDATE/DELETE/UNRESOLVED) - **Lucee** исполняет операции как лог, собирает кумулятивный статус - LLM не участвует в математике сравнения ### 2. Хэш вместо row_num - `md5(lower(trim(name)) || coalesce(date_start,''))` — только неизменяемые идентификаторы - `qty`, `price`, `sum` НЕ входят в хэш — это изменяемые атрибуты - Если `qty` в хэше: ДС меняет количество → новый хэш → ADD вместо UPDATE → дубликат - Уникальность: имя + дата достаточно (две строки с одинаковым именем и датой в одном договоре — крайний случай, решается уточнением name) ### 3. Старый код - `spec_rows` + старый differ остаются параллельно (prod не трогаем) - Первичный договор: старый экстрактор строк - Допники: новый движок с операциями - После обкатки — старый отключаем ### 4. ВМ-прокси как async-буфер для LLM - Lucee через cfhttp висит синхронно до 120с - ВМ (Flask + httpx) забирает задачу, вызывает LLM, отдаёт готовый JSON - Новый endpoint `/llm-ops` в том же `convert_server.py` - Допники обрабатываются по одному (SSE прогресс) ### 5. LLM-промпт (новый, для ДС) - Контекст: полная текущая спецификация (hash + все поля) + текст ДС - 100 строк ~10KB — для 120B модели не проблема - LLM сам определяет full_replace vs изменения построчно - Возвращает частичные изменения (только изменённые поля), Lucee делает COALESCE ### 6. БД: Event Sourcing - `spec_events`: лог операций (contract_id, supplement_id, seq INTEGER per-contract, action, target_hash, new_values JSONB, comment) - `spec_current`: текущий статус (таблица, обновляется при apply) - full_replace → явные DELETE на каждую строку (аудит) - Весь ДС — одна транзакция (атомарность) - Откат целиком по supplement_id ### 7. UNRESOLVED - Тип операции уже определён LLM (ADD/UPDATE/DELETE) - UNRESOLVED — LLM не смог найти хэш для привязки - Оператор в `resolve.cfm`: выпадайка с существующими услугами → привязать хэш - Применяется как UPDATE к выбранной услуге ### 8. UI - `resolve.cfm` — отдельная страница для ручного разрешения - Список UNRESOLVED строк, дропдаун с услугами, кнопка «Подтвердить» - Текущий `view.cfm` — предпросмотр документа (первые 15 строк) ### 9. Триггер пайплайна - `process.cfm?contract_id=X&v=2` — новая версия - Старая кнопка = v1, новая кнопка = v2 (рядом) - Никаких флагов в БД, никакого auto-detect - Когда обкатаем → переключаем дефолтную кнопку ### 10. parsed_text для ДС - `process.cfm` v2 переиспользует ту же логику textify (elements_json → текст), что и v1 - `parser.cfm` НЕ трогать - Текст формируется в process.cfm и передаётся на ВМ ### 11. Первичное заполнение spec_current - `process.cfm?v=2` обрабатывает все supplements с нуля, по порядку - Initial: `current_spec = []` (пустой) → LLM возвращает всё как ADD → spec_current заполнен - Каждый следующий ДС: `current_spec` из уже заполненного spec_current - `spec_rows` не трогаем — старый пайплайн независим - Для существующих контрактов: v2 пересчитывает с нуля через LLM, миграция не нужна ## Технические детали реализации ### Таблицы (новые) ```sql spec_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), contract_id UUID REFERENCES contracts(id), supplement_id UUID REFERENCES supplements(id), seq INTEGER NOT NULL, -- per-contract счётчик action TEXT NOT NULL, -- ADD, UPDATE, DELETE, UNRESOLVED target_hash TEXT, -- хэш услуги new_values JSONB, -- для ADD/UPDATE comment TEXT, resolved_by TEXT, -- кто разрешил UNRESOLVED created_at TIMESTAMPTZ DEFAULT now(), UNIQUE(contract_id, seq) ) spec_current ( contract_id UUID REFERENCES contracts(id), name_hash TEXT NOT NULL, -- md5(lower(trim(name)) || coalesce(date_start,'')) name TEXT, price NUMERIC, qty NUMERIC, sum NUMERIC, date_start TEXT, updated_at TIMESTAMPTZ DEFAULT now(), PRIMARY KEY (contract_id, position_hash) ) ``` ### API ВМ `/llm-ops` ``` POST /llm-ops body: {contract_id, supplement_id, current_spec: [{hash, name, price, qty, sum, date_start}], doc_text: "..."} returns: {operations: [{action, target_hash, new_values, comment}], mode: "partial"|"full_replace"} ``` ## Связанные файлы - Запрос Sonnet: `contractor/Files/sonnet-v2-request.md` - Старый код парсинга: `contractor/parser.cfm` - Старый код сравнения: `contractor/differ.cfm` - Старый код экстракции: `contractor/extractor.cfm` - Текущий process: `contractor/process.cfm` - ВМ-прокси: `contractor/deploy/convert_server.py` - ВМ-nginx: `contractor/deploy/nginx-contracts.conf`