From e47e399f7b24b9018824ed7a229e7b795a083e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 20 Jun 2026 09:55:11 +0400 Subject: [PATCH] session-12: event sourcing architecture v2 --- .../session-12-event-sourcing-architecture.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 History/session-12-event-sourcing-architecture.md diff --git a/History/session-12-event-sourcing-architecture.md b/History/session-12-event-sourcing-architecture.md new file mode 100644 index 0000000..a296de9 --- /dev/null +++ b/History/session-12-event-sourcing-architecture.md @@ -0,0 +1,124 @@ +# Сессия 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`