13 KiB
Всё, у меня полная картина. Финальный план:
Plan: Event Sourcing — Кумулятивный статус договора
TL;DR: Добавить event sourcing поверх prod (не ломая spec_rows). LLM получает текущую спецификацию + текст ДС → возвращает операции → Lucee атомарно применяет лог. Связывание по name_hash, не row_num. ВМ — sync-прокси к LLM для каждого ДС по очереди, Lucee шлёт SSE прогресс после каждого.
Фаза 1 — БД: две новые таблицы
1. Создать spec_events (лог):
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 (материализованное состояние):
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 по порядку:
current_spec←SELECT * FROM spec_current WHERE contract_id=?doc_text← изdocuments.parsed_textдля данного supplement- SSE:
{type:"llm_start", supplement_id} - POST на ВМ
/llm-ops(cfhttp, timeout 130s) - SSE:
{type:"llm_done", ops_count:N} - Вызов
apply_events.cfm(include или cfmodule) - 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 | Добавить в schema() CREATE TABLE для spec_events, spec_current |
| contractor/process.cfm | Добавить новый пайплайн (POST на ВМ + вызов apply_events), старый не трогать |
contractor/apply_events.cfm |
Создать — транзакционная логика apply + rollback + rebuild |
contractor/resolve.cfm |
Создать — UI UNRESOLVED |
| contractor/deploy/convert_server.py | Добавить endpoint /llm-ops |
contractor/deploy/llm_prompt.py |
Создать — формирование промпта |
Не трогать: differ.cfm, spec_rows, старая логика извлечения — всё работает как есть.
Verification
- Загрузить тестовый договор с 2 ДС →
spec_eventsзаполнен,spec_current= правильное кумулятивное состояние - Проверить
full_replaceДС: 100 DELETE + N ADD вspec_events,spec_currentсодержит только новые строки - Намеренно передать кривой JSON от LLM → транзакция откатилась,
spec_currentне тронут rollback_supplement→spec_currentвосстановилась как до того ДС- Получить UNRESOLVED → resolver.cfm, связать вручную →
spec_currentобновлён, status=manual - Проверить что старый
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 после resolvePOST (action=reject):UPDATE spec_events SET status='rejected' WHERE id=?
Файлы
| Файл | Действие |
|---|---|
| contractor/db.cfc | Добавить в schema() CREATE TABLE для spec_events, spec_current |
| contractor/process.cfm | Добавить новый пайплайн (POST на ВМ + вызов apply_events), старый не трогать |
contractor/apply_events.cfm |
Создать — транзакционная логика apply + rollback + rebuild |
contractor/resolve.cfm |
Создать — UI UNRESOLVED |
| contractor/deploy/convert_server.py | Добавить endpoint /llm-ops |
contractor/deploy/llm_prompt.py |
Создать — формирование промпта |
Не трогать: differ.cfm, spec_rows, старая логика извлечения — всё работает как есть.
Verification
- Загрузить тестовый договор с 2 ДС →
spec_eventsзаполнен,spec_current= правильное кумулятивное состояние - Проверить
full_replaceДС: 100 DELETE + N ADD вspec_events,spec_currentсодержит только новые строки - Намеренно передать кривой JSON от LLM → транзакция откатилась,
spec_currentне тронут rollback_supplement→spec_currentвосстановилась как до того ДС- Получить UNRESOLVED → resolver.cfm, связать вручную →
spec_currentобновлён, status=manual - Проверить что старый
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