From bcd937e46f4f330f0479b26e7230127f8c57e3ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 20 Jun 2026 09:55:10 +0400 Subject: [PATCH] v2 architecture: Sonnet plan + request docs --- Files/Sonnet_Plan.md | 237 +++++++++++++++++++++++++++++++++++++ Files/sonnet-v2-request.md | 87 ++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 Files/Sonnet_Plan.md create mode 100644 Files/sonnet-v2-request.md diff --git a/Files/Sonnet_Plan.md b/Files/Sonnet_Plan.md new file mode 100644 index 0000000..83a5cf1 --- /dev/null +++ b/Files/Sonnet_Plan.md @@ -0,0 +1,237 @@ +Всё, у меня полная картина. Финальный план: + +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 \ No newline at end of file diff --git a/Files/sonnet-v2-request.md b/Files/sonnet-v2-request.md new file mode 100644 index 0000000..0a952b2 --- /dev/null +++ b/Files/sonnet-v2-request.md @@ -0,0 +1,87 @@ +## Контекст + +Проект «Сверка договоров» — автоматическое сравнение договоров и допсоглашений (ДС) облачного провайдера. + +### Текущая реализация (Lucee CFML + PostgreSQL) + +**Таблицы:** +- `contracts` (id UUID, number, client) +- `documents` (id UUID, filename, mime_type, original_bytes, elements_json JSONB, parsed_text) +- `supplements` (id UUID, contract_id, document_id, type: 'initial'/'additional') +- `spec_rows` (id UUID, supplement_id, row_num INTEGER, name TEXT, price NUMERIC, qty NUMERIC, sum NUMERIC, date_start TEXT) +- `prompts` (contract_id UUID, prompt_text TEXT) + +**Текущий поток:** +1. Загрузка PDF/DOCX через ВМ-прокси (contracts.kube5s.ru) — т.к. k8s Ingress с ddos-guard рвёт HTTP/2 при прямых POST > ~50KB. ВМ проксирует на Lucee (proxy_buffering off, client_max_body_size 100m). Для .doc — конвертация через libreoffice на ВМ. +2. Парсинг (PDFBox/POI/Tabula) → elements_json (параграфы + таблицы) +3. Для каждого документа отдельный вызов LLM (api.aillm.ru, gpt-oss-120b): + - Промпт: «Извлеки строки спецификации в JSON {rows: [{row_num, name, price, qty, sum, date_start}]}» + - Ответ LLM: JSON со строками +4. Сохраняется в spec_rows (DELETE старых + INSERT новых) +5. Сравнение: differ.cfm сравнивает initial VS каждый additional по row_num → added/deleted/changed + +**Проблемы текущего подхода:** +- Сравнение по row_num — хрупкое. Если в ДС поменялся порядок строк — всё ломается +- Нет кумулятивного статуса — только попарный diff +- LLM используется только как экстрактор, а не как интерпретатор изменений +- Нет обработки неясных ситуаций (LLM не может сказать «не знаю») +- Нет истории версий (event sourcing) + +**Архитектура загрузки (важно):** +- Фронтенд Lucee (contractor.luceek8s.dev.nubes.ru) НЕ может принимать большие POST напрямую — k8s Ingress + ddos-guard рвут HTTP/2 стримы случайным образом (ERR_HTTP2_PROTOCOL_ERROR) +- Загрузка идёт через ВМ-прокси (contracts.kube5s.ru, nginx): proxy_buffering off, client_max_body_size 100m +- ВМ может выполнять дополнительную логику при необходимости (конвертация .doc, валидация, etc.) +- Прямая загрузка на pythonk8s (contractor.pythonk8s.services.ngcloud.ru) имеет ту же проблему с HTTP/2 + +**Инфраструктура:** +- Lucee 6.0 CFML на k8s (contractor.luceek8s.dev.nubes.ru) +- Python Flask на k8s (contractor.pythonk8s.services.ngcloud.ru) +- Python Flask на ВМ (contracts.kube5s.ru) — nginx, libreoffice, прокси для k8s +- PostgreSQL (через JDBC на Lucee, psycopg2 на Python) +- LLM API: api.aillm.ru/v1/chat/completions (gpt-oss-120b, 120B параметров, локальная модель, бесплатно, без ограничений по токенам) +- LLM НЕ поддерживает structured output / JSON mode — только промпт +- Клиентский HTTP/2 через httpx на Python, cfhttp на Lucee +- **Проблема Lucee:** cfhttp блокирует поток на время ответа LLM (до 120с на каждый вызов). Если 5 допников — Lucee висит 10 минут, ничего не отдавая клиенту + +## Что нужно + +Разработать архитектуру и логику **кумулятивного статуса договора** (event sourcing): + +### Идея (требует детальной проработки) +1. **LLM получает**: текущую спецификацию договора (все строки) + полный текст ДС +2. **LLM возвращает**: список операций (ADD/UPDATE/DELETE/UNRESOLVED): +```json +[ + {"action":"UPDATE","target_hash":"abc123","new_values":{"price":150000},"comment":"..."}, + {"action":"DELETE","target_hash":"def456","comment":"..."}, + {"action":"ADD","new_row":{"name":"...","price":...,"qty":...,"sum":...,"date_start":"..."}}, + {"action":"UNRESOLVED","reason":"Не удалось сопоставить строку..."} +] +``` +3. **Lucee исполняет** операции как лог (event sourcing), без хардкода +4. Связывание строк — по хэшу названия услуги (md5 нормализованного имени), НЕ по row_num + +### Требования к архитектуре +- Event sourcing: лог операций, можно восстановить состояние на любую дату +- Обработка «полной замены» спецификации в ДС (пачка DELETE + ADD) +- UNRESOLVED: если LLM не уверен — оператор решает вручную (нужен UI) +- Два режима сравнения: А) ДС → изменения поверх договора, Б) ДС → полная замена спецификации +- Отказоустойчивость: если LLM ошибся, не испортить основные данные +- Поскольку загрузка идёт через ВМ — можно добавить промежуточную логику на ВМ если нужно + +### Вопрос +Разработай полную архитектуру: +1. Структуру таблиц БД (лог операций, кумулятивный статус) +2. Промпт для LLM (как передать контекст: текущую спецификацию + текст ДС) +3. Логику Lucee (CFML) для исполнения лога операций +4. Как обрабатывать UNRESOLVED (UI + процесс) +5. Как откатывать ошибочные операции +6. Нужно ли сохранять старый экстрактор строк или заменить полностью? +7. ВМ-прокси как async-буфер для LLM-вызовов + - Lucee через cfhttp висит синхронно до 120с на каждый вызов LLM + - Предложение: ВМ (Flask + httpx) забирает задачу от Lucee, + асинхронно вызывает LLM, отдаёт готовый JSON команд + - Lucee не блокируется, httpx работает с HTTP/2 надёжнее cfhttp + - Как должен выглядеть API между Lucee и ВМ для этого? + - Нужна ли очередь задач или можно синхронно (ВМ ждёт, Lucee ждёт ВМ)? + - Стоит ли объединить все вызовы LLM в один запрос к ВМ (все ДС сразу)?