Files
contractor/Files/Sonnet_Plan.md

237 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Всё, у меня полная картина. Финальный план:
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