Files
contractor/Files/Sonnet_Plan.md

13 KiB
Raw Permalink Blame History

Всё, у меня полная картина. Финальный план:

Updated memory file


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 по порядку:

  1. current_specSELECT * 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 Добавить в 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

  1. Загрузить тестовый договор с 2 ДС → spec_events заполнен, spec_current = правильное кумулятивное состояние
  2. Проверить full_replace ДС: 100 DELETE + N ADD в spec_events, spec_current содержит только новые строки
  3. Намеренно передать кривой JSON от LLM → транзакция откатилась, spec_current не тронут
  4. rollback_supplementspec_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 после 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

  1. Загрузить тестовый договор с 2 ДС → spec_events заполнен, spec_current = правильное кумулятивное состояние
  2. Проверить full_replace ДС: 100 DELETE + N ADD в spec_events, spec_current содержит только новые строки
  3. Намеренно передать кривой JSON от LLM → транзакция откатилась, spec_current не тронут
  4. rollback_supplementspec_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