Всё, у меня полная картина. Финальный план: Updated memory file [](file:///home/naeel/.vscode-server/data/User/workspaceStorage/dc98ff3e658364e1826ee6585ebe4b5e/GitHub.copilot-chat/memory-tool/memories/NjU5ZThiMjEtYzVmOS00YWY1LTgyNDktM2YyNTMxZjU4MWNj/plan.md) --- ## Plan: Event Sourcing — Кумулятивный статус договора **TL;DR:** Добавить event sourcing поверх prod (не ломая `spec_rows`). LLM получает текущую спецификацию + текст ДС → возвращает операции → Lucee атомарно применяет лог. Связывание по `name_hash`, не `row_num`. ВМ — sync-прокси к LLM для каждого ДС по очереди, Lucee шлёт SSE прогресс после каждого. --- ### Фаза 1 — БД: две новые таблицы **1.** Создать `spec_events` (лог): ```sql id UUID PK, contract_id UUID FK, supplement_id UUID FK, seq INTEGER NOT NULL, -- per-contract, UNIQUE(contract_id, seq) action TEXT CHECK IN ('ADD','UPDATE','DELETE','UNRESOLVED'), target_hash TEXT, -- для UPDATE/DELETE/UNRESOLVED new_values JSONB, -- ADD: полная строка; UPDATE: только изменённые поля; UNRESOLVED: что LLM извлёк comment TEXT, -- объяснение LLM / reason для UNRESOLVED status TEXT DEFAULT 'applied' -- 'applied'|'pending'|'rejected'|'manual' created_at TIMESTAMPTZ ``` **2.** Создать `spec_current` (материализованное состояние): ```sql id UUID PK, contract_id UUID FK, name_hash TEXT NOT NULL, -- md5(lower(trim(name)) || coalesce(date_start,'') || coalesce(qty::text,'')) name TEXT, price NUMERIC, qty NUMERIC, sum NUMERIC, date_start TEXT, last_event_id UUID FK → spec_events, updated_at TIMESTAMPTZ, UNIQUE(contract_id, name_hash) ``` `name_hash` вычисляется в PostgreSQL через `md5()` в INSERT-запросе — никаких расхождений между CFML и SQL. --- ### Фаза 2 — ВМ: endpoint `/llm-ops` в `convert_server.py` **3.** Добавить endpoint `POST /llm-ops` в существующий Flask-app: - Вход: `{contract_id, supplement_id, current_spec: [{hash, name, price, qty, sum, date_start}], doc_text}` - Вызывает LLM через httpx (sync внутри endpoint, timeout 120s) - Парсит JSON из ответа (обёртка ` ```json ``` ` уже обрабатывается в коде) - Выход: `{mode: "partial"|"full_replace", ops: [...]}` **4.** Добавить `llm_prompt.py` рядом — функция `build_prompt(current_spec, doc_text)`: Структура промпта: - System: «Анализируй ДС. Отвечай ТОЛЬКО JSON.» - User — блок A: текущая спецификация (полный JSON с hash/name/price/qty/sum/date_start) - User — блок B: текст ДС - Задача: вернуть `{mode, ops}` - `mode = "full_replace"` если в тексте есть: «в следующей редакции», «заменить приложение», «излагается в следующей редакции» - Операции: - `ADD`: `{action, new_row: {name, price, qty, sum, date_start}, comment}` - `UPDATE`: `{action, target_hash, new_values: {только изменённые поля}, comment}` - `DELETE`: `{action, target_hash, comment}` - `UNRESOLVED`: `{action, new_values: {что LLM смог извлечь}, reason}` — только когда LLM видит изменение существующей услуги, но не может определить какой именно --- ### Фаза 3 — Lucee: `apply_events.cfm` **5.** Новый файл `apply_events.cfm` — транзакционное применение пачки ops: ``` BEGIN TRANSACTION seq = SELECT COALESCE(MAX(seq), 0) FROM spec_events WHERE contract_id=? if mode = 'full_replace': -- Аудит: записать явный DELETE для каждой текущей строки for row in (SELECT * FROM spec_current WHERE contract_id=?): seq++ INSERT spec_events (action='DELETE', target_hash=row.name_hash, status='applied', seq=seq) DELETE FROM spec_current WHERE contract_id=? for each op in ops: seq++ if op.action = 'ADD': hash = md5(...) вычислить в INSERT-запросе INSERT spec_current ... ON CONFLICT → RAISE ERROR event_id = INSERT spec_events (action='ADD', target_hash=hash, new_values=op.new_row, status='applied') UPDATE spec_current SET last_event_id=event_id WHERE contract_id=? AND name_hash=hash if op.action = 'UPDATE': old_row = SELECT * FROM spec_current WHERE contract_id=? AND name_hash=op.target_hash if not found → RAISE ERROR "target not found: {hash}" event_id = INSERT spec_events (action='UPDATE', target_hash, new_values=op.new_values, status='applied') UPDATE spec_current SET price = COALESCE(op.new_values.price, old_row.price), qty = COALESCE(op.new_values.qty, old_row.qty), sum = COALESCE(op.new_values.sum, old_row.sum), date_start = COALESCE(op.new_values.date_start, old_row.date_start), last_event_id = event_id WHERE contract_id=? AND name_hash=op.target_hash if op.action = 'DELETE': if not EXISTS → RAISE ERROR INSERT spec_events (action='DELETE', target_hash, status='applied') DELETE FROM spec_current WHERE contract_id=? AND name_hash=op.target_hash if op.action = 'UNRESOLVED': INSERT spec_events (action='UNRESOLVED', new_values, comment=op.reason, status='pending') -- spec_current НЕ трогать COMMIT -- любая RAISE ERROR → ROLLBACK всего ДС ``` **6.** Изменить `process.cfm` — добавить новый пайплайн параллельно со старым: Для каждого supplement по порядку: 1. `current_spec` ← `SELECT * FROM spec_current WHERE contract_id=?` 2. `doc_text` ← из `documents.parsed_text` для данного supplement 3. SSE: `{type:"llm_start", supplement_id}` 4. POST на ВМ `/llm-ops` (cfhttp, timeout 130s) 5. SSE: `{type:"llm_done", ops_count:N}` 6. Вызов `apply_events.cfm` (include или cfmodule) 7. SSE: `{type:"applied", summary:{added, updated, deleted, unresolved}}` После всех ДС: SSE `{type:"done", total_unresolved:N}` --- ### Фаза 4 — Откат и rebuild **7.** Функция `rollback_supplement(contract_id, supplement_id)` в `apply_events.cfm`: ``` BEGIN TRANSACTION UPDATE spec_events SET status='rejected' WHERE supplement_id=? -- Rebuild с нуля DELETE FROM spec_current WHERE contract_id=? events = SELECT * FROM spec_events WHERE contract_id=? AND status IN ('applied','manual') ORDER BY seq for each event → replay (та же логика apply, но без записи в spec_events) COMMIT ``` Replay для UPDATE безопасен: spec_current строится последовательно, к моменту UPDATE нужная строка уже есть от предыдущего ADD. --- ### Фаза 5 — UI для UNRESOLVED: `resolve.cfm` **8.** Создать `resolve.cfm`: **GET** `?contract_id=...`: - Запрос 1: `SELECT * FROM spec_events WHERE contract_id=? AND action='UNRESOLVED' AND status='pending'` - Запрос 2: `SELECT name_hash, name FROM spec_current WHERE contract_id=?` — для дропдауна - HTML: таблица с pending событиями; для каждого — `reason`, `new_values`, дропдаун услуг, кнопки «Связать» / «Отклонить» **POST** (action=apply): `{event_id, target_hash}` ``` BEGIN TRANSACTION event = SELECT * FROM spec_events WHERE id=? AND status='pending' -- verify old_row = SELECT * FROM spec_current WHERE contract_id=event.contract_id AND name_hash=target_hash UPDATE spec_current SET price = COALESCE(event.new_values.price, old_row.price), ... last_event_id = event.id UPDATE spec_events SET status='manual', target_hash=target_hash WHERE id=? COMMIT ``` **POST** (action=reject): `UPDATE spec_events SET status='rejected' WHERE id=?` --- ### Файлы | Файл | Действие | |------|---------| | [contractor/db.cfc](contractor/db.cfc) | Добавить в `schema()` CREATE TABLE для `spec_events`, `spec_current` | | [contractor/process.cfm](contractor/process.cfm) | Добавить новый пайплайн (POST на ВМ + вызов apply_events), старый не трогать | | `contractor/apply_events.cfm` | Создать — транзакционная логика apply + rollback + rebuild | | `contractor/resolve.cfm` | Создать — UI UNRESOLVED | | [contractor/deploy/convert_server.py](contractor/deploy/convert_server.py) | Добавить endpoint `/llm-ops` | | `contractor/deploy/llm_prompt.py` | Создать — формирование промпта | **Не трогать:** `differ.cfm`, `spec_rows`, старая логика извлечения — всё работает как есть. --- ### Verification 1. Загрузить тестовый договор с 2 ДС → `spec_events` заполнен, `spec_current` = правильное кумулятивное состояние 2. Проверить `full_replace` ДС: 100 DELETE + N ADD в `spec_events`, `spec_current` содержит только новые строки 3. Намеренно передать кривой JSON от LLM → транзакция откатилась, `spec_current` не тронут 4. `rollback_supplement` → `spec_current` восстановилась как до того ДС 5. Получить UNRESOLVED → resolver.cfm, связать вручную → `spec_current` обновлён, status=manual 6. Проверить что старый `differ.cfm` по `spec_rows` работает как прежде --- ### Scope boundaries - **Включено:** новые таблицы + ВМ endpoint + apply_events + resolve.cfm + интеграция в process.cfm - **Исключено:** миграция старых данных из `spec_rows` в `spec_current`, очередь задач (Redis/Celery), параллельная обработка ДС, UI откатов - **Решения:** per-contract seq, partial new_values + COALESCE, явные DELETE при full_replace (аудит), `spec_current` как реальная таблица, `/llm-ops` в существующем Flask-app, UNRESOLVED = всегда UPDATE после resolve**POST** (action=reject): `UPDATE spec_events SET status='rejected' WHERE id=?` --- ### Файлы | Файл | Действие | |------|---------| | [contractor/db.cfc](contractor/db.cfc) | Добавить в `schema()` CREATE TABLE для `spec_events`, `spec_current` | | [contractor/process.cfm](contractor/process.cfm) | Добавить новый пайплайн (POST на ВМ + вызов apply_events), старый не трогать | | `contractor/apply_events.cfm` | Создать — транзакционная логика apply + rollback + rebuild | | `contractor/resolve.cfm` | Создать — UI UNRESOLVED | | [contractor/deploy/convert_server.py](contractor/deploy/convert_server.py) | Добавить endpoint `/llm-ops` | | `contractor/deploy/llm_prompt.py` | Создать — формирование промпта | **Не трогать:** `differ.cfm`, `spec_rows`, старая логика извлечения — всё работает как есть. --- ### Verification 1. Загрузить тестовый договор с 2 ДС → `spec_events` заполнен, `spec_current` = правильное кумулятивное состояние 2. Проверить `full_replace` ДС: 100 DELETE + N ADD в `spec_events`, `spec_current` содержит только новые строки 3. Намеренно передать кривой JSON от LLM → транзакция откатилась, `spec_current` не тронут 4. `rollback_supplement` → `spec_current` восстановилась как до того ДС 5. Получить UNRESOLVED → resolver.cfm, связать вручную → `spec_current` обновлён, status=manual 6. Проверить что старый `differ.cfm` по `spec_rows` работает как прежде --- ### Scope boundaries - **Включено:** новые таблицы + ВМ endpoint + apply_events + resolve.cfm + интеграция в process.cfm - **Исключено:** миграция старых данных из `spec_rows` в `spec_current`, очередь задач (Redis/Celery), параллельная обработка ДС, UI откатов - **Решения:** per-contract seq, partial new_values + COALESCE, явные DELETE при full_replace (аудит), `spec_current` как реальная таблица, `/llm-ops` в существующем Flask-app, UNRESOLVED = всегда UPDATE после resolve