diff --git a/History/opus-prompt-v2.md b/History/opus-prompt-v2.md new file mode 100644 index 0000000..ac88c4f --- /dev/null +++ b/History/opus-prompt-v2.md @@ -0,0 +1,61 @@ +# Opus Prompt Improvement — Few-Shot + Domain Glossary + +Дата: 2026-06-23 | Источник: Opus (раунд 3, старый чат) +Связано: llm_prompt.py, prompt-strategy.md, provenance-columns.md + +--- + +## Что попросили у Opus + +Улучшить промпты для extract (первый документ) и diff (сравнение ДС). Ключевые требования: +- Few-shot примеры на домене ЦОД/colocation +- Доменный глоссарий (кВт, юнит, стойко-место, IP, каналы) +- Edge-cases инструкция +- JSON-формат не менять +- Новые actions не добавлять + +## Что Opus выдал + +### EXTRACT +- Добавлен доменный глоссарий +- 1 few-shot пример (стойко-место + IP-адрес) +- Усилены правила чисел и null + +### DIFF +- Добавлен доменный глоссарий +- 4 few-shot примера: + 1. `partial`, UPDATE цены + 2. `partial`, ADD + UPDATE qty + UPDATE мощности (внутри name) + 3. `full_replace` (новая редакция приложения) + 4. `UNRESOLVED` (не с чем сопоставить) +- Edge-cases: сопоставление по смыслу (не по символам), qty++ = UPDATE, мощность внутри name, приоритет full_replace, null при отсутствии данных + +## Какие проблемы решает + +| Проблема | Решение | +|----------|---------| +| Модель путает единицы (кВт vs шт.) | Глоссарий | +| Не понимает что «Аренда стойко-места» = «Colocation» | Сопоставление по смыслу | +| При увеличении qty создаёт ADD вместо UPDATE | Пример 2 | +| full_replace: пишет UPDATE вместо ADD | Пример 3 | +| Не создаёт UNRESOLVED для незнакомых услуг | Пример 4 | +| «55 000,00 руб.» → строка вместо числа | Правило чисел | + +## Размещение + +Два варианта: +1. Заменить `FALLBACK_EXTRACT` / `FALLBACK_DIFF` в `llm_prompt.py` +2. Обновить активный промпт в БД через UI (вкладка «⚙ Промпты») + +**Рекомендация:** оба. Fallback в Python — защита если БД недоступна. БД — основной источник. Сделать одновременно. + +## ⚠️ Важно: фигурные скобки в Python + +В Python-строке (тройные кавычки) `{` и `}` НЕ требуют удвоения — они не являются f-string placeholder'ами. Удвоение (`{{`, `}}`) нужно ТОЛЬКО если используется `.format()`. В текущем коде промпты — обычные строки в тройных кавычках, подстановка через `.replace()`. Поэтому фигурные скобки оставляем одинарными. + +## Статус + +- [x] Opus выдал готовые промпты +- [ ] Заменить в `llm_prompt.py` +- [ ] Обновить в БД (сохранить как новую версию) +- [ ] Bump + пуш + синк VM diff --git a/History/prompt-strategy.md b/History/prompt-strategy.md new file mode 100644 index 0000000..6545678 --- /dev/null +++ b/History/prompt-strategy.md @@ -0,0 +1,168 @@ +# Prompt Management Strategy + +Дата: 2026-06-23 | Версия: v1.0 | Контекст: v1.0.109, обсуждение архитектуры промптов + +--- + +## А. Несколько промптов — зачем и как + +Смешиваются **две разные потребности** — их нельзя путать: + +### 1. Версионирование одного промпта (ОБЯЗАТЕЛЬНО) + +Это не «несколько промптов», а история эволюции одного. + +**Почему критично:** +- `prompt_version` уже кладётся в `spec_events` (provenance, Opus раунд 2). +- Значит промпт **обязан быть иммутабельным** — правка «на месте» сломает трассировку: старые события будут ссылаться на текст, которого больше нет. +- **Правка = новая версия**, а не перезапись. Откат, аудит «какая версия породила какой результат», сравнение версий — бесплатно. + +### 2. Несколько РАЗНЫХ промптов одновременно + +| Сценарий | Вердикт | +|----------|---------| +| **По роли в пайплайне** — разные LLM-вызовы (извлечение услуг vs сравнение vs резолв имён) | ✅ У каждого свой промпт. Настоящая причина «несколько» — по **задаче**. | +| **Варианты для эксперимента** — форкнул активный, сделал строже, прогнал на тестах, сравнил | ✅ Loop «дублировать → изменить → протестировать → активировать» — правильно. | +| **Каталог из 20 промптов «на всякий случай»** | ❌ Ловушка. Для узкого домена (colocation/ЦОД) нужен **ОДИН хороший** промпт на задачу. | + +### Рекомендуемая модель: prompt-as-version (не prompt-as-document) + +Одна таблица, один активный на `role`, вся история — строки: + +| Поле | Тип | Смысл | +|------|-----|-------| +| `id` | UUID PK | Версия (иммутабельная) | +| `role` | VARCHAR | Шаг пайплайна: `extract`, `diff` | +| `name` | VARCHAR | Человеческое имя («default-v3», «strict-prices») | +| `body` | TEXT | Тело промпта с плейсхолдерами | +| `parent_id` | UUID FK→prompts.id | От какой версии форкнут | +| `is_active` | BOOLEAN | UNIQUE(role, is_active) с частичным индексом WHERE is_active | +| `created_at` | TIMESTAMP | | +| `created_by` | VARCHAR | Мульти-юзер | +| `notes` | TEXT | Что изменили и зачем | + +**Операции:** +- **Править** = `INSERT` новой строки с `parent_id` на предыдущую. Не `UPDATE`. +- **Активировать** = переключить `is_active` (старая теряет флаг). +- **Дублировать** = копия под новым `name` (форк). +- **Откат** = активировать старую версию. + +**Никогда не UPDATE текста на месте.** + +--- + +## Б. Как составлять промпт + +### Стартовый шаблон — ОБЯЗАТЕЛЕН + +Пустое поле ввода — катастрофа для качества. Всегда форкать от выверенного дефолта, не с нуля. + +### Структура (8 секций, порядок важен) + +Для доменной задачи **без structural output** (gpt-oss-120b): + +``` +┌─────────────────────────────────────────────┐ +│ 1. РОЛЬ / КОНТЕКСТ │ +│ «ты эксперт по договорам colocation │ +│ и облачных услуг ЦОД» │ +├─────────────────────────────────────────────┤ +│ 2. ЗАДАЧА │ +│ «сравни текущую спецификацию с документом,│ +│ верни операции изменений» │ +├─────────────────────────────────────────────┤ +│ 3. ДОМЕННЫЙ ГЛОССАРИЙ │ +│ стойко-место, кВт мощности, colocation, │ +│ единицы измерения, типы услуг. │ +│ Без этого модель путает домен. │ +├─────────────────────────────────────────────┤ +│ 4. ФОРМАТ ВХОДА │ +│ Как подаётся spec_current (с ID r1..rN), │ +│ как подаётся текст документа. │ +├─────────────────────────────────────────────┤ +│ 5. КОНТРАКТ ВЫВОДА │ +│ Строгая JSON-схема ops: поля, типы, │ +│ обязательность. КРИТИЧНО — structural │ +│ output нет, схема держится промптом. │ +├─────────────────────────────────────────────┤ +│ 6. ПРАВИЛА / ОГРАНИЧЕНИЯ │ +│ «ссылайся только на id из spec_current», │ +│ «верни source_quote», «верни confidence», │ +│ «name существующих строк не переписывай» │ +├─────────────────────────────────────────────┤ +│ 7. FEW-SHOT ПРИМЕРЫ (2-3) │ +│ ⭐ САМЫЙ МОЩНЫЙ РЫЧАГ для бесплатной │ +│ модели без fine-tune. Примеры учат и │ +│ формату, и поведению лучше инструкций. │ +│ Вход→Выход на ВАШЕМ домене (ЦОД). │ +├─────────────────────────────────────────────┤ +│ 8. EDGE-CASES │ +│ Как помечать неоднозначность, что делать │ +│ при low-confidence. │ +└─────────────────────────────────────────────┘ +``` + +### Принцип разделения: статика vs рантайм + +``` +[версионируемая часть — в БД] [рантайм-инъекция — в коде] +роль + глоссарий + правила + {spec_current} + {document_text} ++ few-shot + контракт вывода +``` + +- Статическая инструкция (секции 1-8) → **версионируется** в `prompts.body`. +- Динамические данные (`{spec_current}`, `{document_text}`, `{date}`) → **подставляются в рантайме** через плейсхолдеры. +- В БД хранится тело промпта **с плейсхолдерами**, не с конкретными данными. + +--- + +## В. Prompt CI — как с этим работать + +``` + ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────┐ + │ Дублировать │ → │ Изменить │ → │ Прогнать на │ → │ Активировать │ + │ (форк) │ │ (новая v) │ │ golden-наборе│ │ (is_active) │ + └──────────┘ └──────────┘ └───────────┘ └──────────┘ +``` + +1. Форкнуть активный промпт → новый `id`, `is_active=false` +2. Изменить тело +3. Прогнать на golden-наборе (3-5 реальных ДС с известным результатом) +4. Сравнить метрики (precision/recall по ops) +5. Если лучше — активировать. Если хуже — оставить как эксперимент или удалить. + +--- + +## Г. Текущее состояние (v1.0.109) + +Промпты жёстко зашиты в `llm_prompt.py`: +- `_build_initial()` — для первого документа +- `_build_diff()` — для сравнения (основной) + +**Что уже хорошо:** +- JSON-контракт вывода описан +- `target_id` (r1..rN) вместо `target_hash` +- Правила: mode full_replace, UPDATE только изменённые поля, UNRESOLVED для неоднозначного + +**Чего не хватает (по приоритету):** +1. Few-shot примеры на домене ЦОД — самый большой рычаг качества +2. Доменный глоссарий — чтобы модель понимала единицы измерения и термины +3. `source_quote` в контракте вывода — для provenance +4. `confidence` в контракте вывода — самооценка модели +5. Edge-cases инструкция — как вести себя при неоднозначности + +--- + +## Д. План миграции (если делать) + +| Шаг | Что | Сложность | +|-----|-----|-----------| +| 1 | Таблица `prompts` в PostgreSQL (Lucee datasource `baza`) | Низкая | +| 2 | CRUD в `api.cfm` для prompts (list, get, create, set_active) | Средняя | +| 3 | `llm_prompt.py` → читает активный промпт из Lucee API вместо хардкода | Средняя | +| 4 | `prompt_version` в `spec_events` — ссылка на `prompts.id` | Низкая | +| 5 | UI для prompts в `index.cfm` (список версий, редактор, активация) | Высокая | +| 6 | Golden-набор: 3-5 ДС + эталонные ops | Ручная работа | +| 7 | Prompt CI: скрипт прогона на golden-наборе + сравнение метрик | Средняя | + +**Первый практический шаг** (максимум пользы, минимум кода): добавить few-shot примеры в текущие промпты прямо в `llm_prompt.py`, не дожидаясь таблицы `prompts`. diff --git a/History/provenance-columns.md b/History/provenance-columns.md new file mode 100644 index 0000000..9eeef14 --- /dev/null +++ b/History/provenance-columns.md @@ -0,0 +1,44 @@ +# Provenance Columns — Opus Round 2 + +Дата: 2026-06-23 | Версия: v1.0.114 | Связано: spec_events, apply_events.cfm, convert_server.py + +--- + +## Что сделано + +Добавлены две колонки в `spec_events`, рекомендованные Opus (раунд 2, ответ 6): + +| Колонка | Тип | Откуда берётся | Зачем | +|---------|-----|----------------|-------| +| `source_document_id` | UUID FK→documents | `convert_server.py` — из JOIN supplements+documents | Прямая ссылка на исходный документ (provenance). Без неё — только через supplement_id, что хрупко. | +| `raw_llm_response` | JSONB | `convert_server.py` — полный `llm_result` (mode + ops) | Аудит: что модель ВООБЩЕ ответила vs что мы применили. Воспроизводимость. | + +Вместе с `prompt_version` (v1.0.113) — полный комплект provenance для каждой операции. + +## Почему + +Opus: «Добавилась услуга X за 15000» без источника — бесполезно. «Добавилась услуга X ← допник №3, п.2.4, вот абзац» → проверка за 2 секунды. + +Три колонки закрывают: +- **Каким промптом** (`prompt_version`) +- **Из какого документа** (`source_document_id`) +- **Что модель ответила** (`raw_llm_response`) + +## Что изменилось + +### convert_server.py +- SQL запрос supplements: добавлен `d.id as document_id` +- apply_events POST: добавлены `document_id` и `raw_llm_response` + +### apply_events.cfm +- 3× ALTER TABLE ADD COLUMN IF NOT EXISTS (prompt_version, source_document_id, raw_llm_response) +- Все 7 INSERT INTO spec_events: +2 колонки с `` + +## Что НЕ изменилось +- Логика сравнения +- spec_current +- UI +- Старые строки — NULL в новых колонках, ни на что не влияет + +## Риски +- Нулевые. Колонки опциональные (NULL разрешён), чисто аддитивные. diff --git a/History/service-description.md b/History/service-description.md new file mode 100644 index 0000000..a6dadf2 --- /dev/null +++ b/History/service-description.md @@ -0,0 +1,71 @@ +# Сверка договоров — описание сервиса + +## Что делает + +Сервис автоматически сравнивает договоры и дополнительные документы облачного провайдера (colocation, ЦОД). Юрист загружает файлы (.docx/.doc/.pdf), система извлекает спецификации услуг и при помощи LLM находит изменения: что добавилось, изменилось, удалилось. + +## Как устроен пайплайн + +``` +Загрузка → Парсинг → Порядок → LLM-сравнение → Результаты +``` + +1. **Загрузка** — файлы принимаются через веб-интерфейс. Поддерживаются .docx, .doc (старый Word), .pdf, а также .zip с несколькими файлами. + +2. **Парсинг** — каждый файл автоматически разбирается: извлекаются таблицы и текст. Используется Apache POI (Java) для Word-документов и PDFBox для PDF. Результат — структурированный JSON (elements_json) и текстовое представление. + +3. **Порядок** — файлы можно переставить стрелками ↕. Первый в списке считается базовым договором, остальные — дополнительные документы к нему. + +4. **LLM-сравнение** — каждый дополнительный документ последовательно сравнивается с текущей спецификацией. Модель (gpt-oss-120b) получает промпт с текущим списком услуг, текстом дополнительного документа и возвращает операции: + - **ADD** — новая услуга + - **UPDATE** — изменение цены, количества, названия + - **DELETE** — услуга исключена + - **UNRESOLVED** — не удалось однозначно сопоставить + +5. **Результаты** — накапливаются по цепочке дополнительных документов (Event Sourcing). Каждый следующий дополнительный документ учитывает изменения из предыдущих. Итоговая спецификация — сумма всех применённых операций. + +## Архитектура + +``` +Браузер (index.cfm + JS) + │ + ├── загрузка файлов ──→ VM (Python, convert_server.py) + │ │ + │ ├── /convert-doc ──→ Lucee (parser.cfm) + │ ├── /process-v2 ───→ Lucee (apply_events.cfm) + │ └── LLM (api.aillm.ru, gpt-oss-120b) + │ + └── API ──→ Lucee (CFML на k8s) + │ + └── PostgreSQL 15 (документы, спецификации, события, промпты) +``` + +| Компонент | Где | Технология | +|-----------|-----|------------| +| Веб-интерфейс | Lucee 6.0 (k8s) | CFML + JavaScript | +| База данных | Внутренний PostgreSQL 15 | JSONB, UUID, advisory locks | +| Парсинг документов | Lucee | Apache POI (HWPF/XWPF), PDFBox | +| LLM-анализ | Внешняя VM (5.172.178.213) | Python 3, httpx, SSE-стриминг | +| Модель | api.aillm.ru | gpt-oss-120b (бесплатно, 8000 токенов) | + +## Event Sourcing + +Изменения не перезаписывают спецификацию — каждая операция сохраняется как событие в `spec_events`. Текущее состояние (`spec_current`) — материализованное представление всех событий. + +Это даёт: +- **Аудит** — кто/when/откуда каждая строка +- **Откат** — можно пересобрать состояние на любой момент +- **Provenance** — ссылка на документ-источник, версию промпта, полный ответ LLM + +## Промпты + +Промпты для LLM хранятся в БД и версионируются. Есть два: для первого документа (извлечение) и для сравнения дополнительных документов. Встроенный редактор с историей версий позволяет улучшать промпты без правки кода: сохранил новую версию → она сразу используется LLM. Старые версии остаются в истории, можно откатиться. + +## Стек + +- **Backend**: Lucee 6.0 (CFML) на Kubernetes +- **База**: PostgreSQL 15 (JSONB, UUID, window functions) +- **Парсинг**: Apache POI (Java, встроен в Lucee) +- **LLM-прокси**: Python 3 + httpx + threading (SSE) +- **Модель**: gpt-oss-120b (OpenAI-совместимый API) +- **Фронтенд**: ванильный JS + Lucide иконки diff --git a/History/sonnet-zip-cors.md b/History/sonnet-zip-cors.md new file mode 100644 index 0000000..71a588b --- /dev/null +++ b/History/sonnet-zip-cors.md @@ -0,0 +1,79 @@ +# Sonnet Analysis — ZIP Upload CORS + Multipart + +Дата: 2026-06-23 | Источник: Sonnet (новый чат) +Связано: index.cfm, convert_server.py, nginx-contracts.conf + +--- + +## Текущее состояние + +- JS на `contractor.luceek8s.dev.nubes.ru` шлёт FormData через fetch на `contracts.kube5s.ru/unzip-upload` +- VM (Python, 8766) парсит multipart через `email.parser.BytesParser` +- Nginx проксирует `/unzip-upload` → VM:8766 +- CURL работает, браузер — `Failed to fetch` + +## Диагноз Sonnet + +### 1. CORS в nginx — add_header внутри if не работает + +`add_header` в родительском `location` не применяется к ответу из `if (...) { return 200; }` — это новый контекст. + +**Исправление:** заголовки внутрь `if`: + +```nginx +location /unzip-upload { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "POST, OPTIONS"; + add_header Access-Control-Allow-Headers "*"; + add_header Content-Length 0; + return 204; + } + add_header Access-Control-Allow-Origin "*"; + proxy_pass http://127.0.0.1:8766; + client_max_body_size 100m; +} +``` + +### 2. Multipart парсинг — filename*= кодировка + +Браузер для файлов с не-ASCII именами использует `filename*=UTF-8''...` (RFC 5987). `part.get_filename()` может вернуть None. + +**Решение:** переход на raw binary (п.3) устраняет проблему полностью. + +### 3. Raw binary — лучший вариант + +**JS:** +```javascript +var zipResp = await fetch(UNZIP_URL, { + method: 'POST', + body: f, + headers: { 'Content-Type': 'application/zip' } +}); +``` + +**Python:** +```python +if "multipart" in content_type: + # fallback для curl + ... +else: + zip_data = self.rfile.read(length) +``` + +**Плюсы:** +- Нет multipart overhead +- Нет проблем с filename-кодировкой +- Нет проблем с boundary + +**Минусы:** +- Нужен правильный CORS preflight (Content-Type: application/zip — не simple) +- Нужен `client_max_body_size` в nginx + +## План реализации (когда «делай») + +1. nginx: перенести CORS-заголовки внутрь `if`-блока +2. JS: `fetch(UNZIP_URL, { method: 'POST', body: f, headers: {'Content-Type': 'application/zip'} })` +3. VM: raw binary как основной путь, multipart как fallback +4. Убрать FormData из JS +5. Bump, пуш, синк VM + nginx diff --git a/contractor b/contractor new file mode 160000 index 0000000..ec842ab --- /dev/null +++ b/contractor @@ -0,0 +1 @@ +Subproject commit ec842abb926c1e4904c440994946d4c623681e6a diff --git a/history/Sonnet_1718.md b/history/Sonnet_1718.md new file mode 100644 index 0000000..8b102b5 --- /dev/null +++ b/history/Sonnet_1718.md @@ -0,0 +1,145 @@ +Read [](file:///home/naeel/nubes/contracts/contractor/apply_events.cfm) + +Read [](file:///home/naeel/nubes/contracts/contractor/db.cfc#1-1), lines 1 to 150 + +Read [](file:///home/naeel/nubes/contracts/contractor/deploy/llm_prompt.py) + +Read [](file:///home/naeel/nubes/contracts/contractor/deploy/convert_server.py#1-1), lines 1 to 150 + +Read [](file:///home/naeel/nubes/contracts/contractor/deploy/convert_server.py#150-150), lines 150 to 250 + +Проанализировал все файлы. Вот подробный аудит. + +--- + +## Аудит Event Sourcing — сверка договоров + +--- + +### Находки Gemini — подтверждение/опровержение + +**1. Race condition `MAX(seq)` — ✅ ПОДТВЕРЖДЕНО, критично** + +apply_events.cfm: `SELECT COALESCE(MAX(seq), 0)` → `seq++` в цикле — это read-modify-write без блокировки. Два параллельных вызова (разные supplements) получат одинаковый `MAX(seq)` и сгенерируют одинаковые seq. В DDL есть `UNIQUE(contract_id, seq)` — транзакция упадёт с ошибкой дублирующегося ключа. + +**Рекомендация:** Заменить на `nextval` через sequence (`CREATE SEQUENCE spec_events_seq`) или PostgreSQL `INSERT ... RETURNING` с `seq = (SELECT COALESCE(MAX(seq),0)+1 FROM spec_events WHERE contract_id=... FOR UPDATE)` — с `FOR UPDATE` для пессимистической блокировки. + +--- + +**2. `full_replace` с пустым `ops` стирает спецификацию — ✅ ПОДТВЕРЖДЕНО, критично** + +apply_events.cfm: Если LLM вернул `mode=full_replace` с пустым `ops[]` (например, не распознал таблицу), код сначала DELETE всех строк из `spec_current`, потом цикл по ops не выполняется. Спецификация обнулена без восстановления. + +**Рекомендация:** Добавить guard: если `mode == full_replace` и `len(ops) == 0` — отклонить с ошибкой, не трогать `spec_current`. + +--- + +**3. LLM возвращает строку вместо числа → `cf_sql_float` падает — ✅ ПОДТВЕРЖДЕНО** + +apply_events.cfm: Переменные `pr`, `qt`, `sm` берутся напрямую из `op.new_row`. Промпт в llm_prompt.py говорит «ЧИСЛА, не строки», но LLM может вернуть `"1 000,00"` или `"null"` как строку. `cf_sql_float` с нечисловой строкой кидает исключение внутри транзакции — вся транзакция откатывается. + +**Рекомендация:** Перед передачей в cfqueryparam привести к числу через `val()` или попытаться `javacast("double", pr)` с catch. + +--- + +**4. N+1 SELECT в цикле UPDATE — ✅ ПОДТВЕРЖДЕНО** + +apply_events.cfm: Для каждого UPDATE-op выполняется отдельный `SELECT price, qty, sum, date_start FROM spec_current WHERE name_hash=?`. Если ДС обновляет 50 строк — 50 SELECT-запросов внутри одной транзакции. + +**Рекомендация:** Перед циклом собрать все `target_hash` UPDATE-ops, сделать один `SELECT ... WHERE name_hash = ANY(ARRAY[...])`, сложить результат в struct по hash. + +--- + +**5. Построчный INSERT в `full_replace` — ✅ ПОДТВЕРЖДЕНО** + +apply_events.cfm: В `full_replace`-блоке цикл по `curRows` выполняет по одному `INSERT INTO spec_events` на каждую строку. После — снова цикл по `ops`, каждый ADD — ещё 2 INSERT. Для 100-строчной спецификации: 100+100×2 = 300 отдельных INSERT внутри одной транзакции. + +**Рекомендация:** Использовать `INSERT INTO spec_events ... SELECT unnest(...)` или сформировать multi-row VALUES через ColdFusion loop перед запросом. + +--- + +**6. `name_hash` зависит от LLM-форматирования — ✅ ПОДТВЕРЖДЕНО, фундаментальная проблема** + +apply_events.cfm: `md5(lower(trim(name)) || coalesce(date_start, ''))` вычисляется на стороне PostgreSQL из данных, которые LLM только что вернул. В llm_prompt.py: в `_build_diff` LLM видит `[hash: {hash}]` и должен вернуть этот же hash в `target_hash`. Но LLM может сократить название, изменить регистр или пробелы → `md5` будет другим → UPDATE-op не найдёт строку → `cfthrow "UPDATE target not found"`. + +**Рекомендация:** Это структурная проблема. Варианты: +- Передавать LLM только `hash` как непрозрачный идентификатор (уже так и делается), но добавить fuzzy-matching на стороне apply: если `target_hash` не найден — искать по близкому имени и создавать UNRESOLVED вместо throw. +- Добавить отдельный endpoint для ручного разрешения UNRESOLVED. + +--- + +**7. `last_event_id` UUID → `cf_sql_varchar` — ✅ ПОДТВЕРЖДЕНО** + +apply_events.cfm: `` для колонки `last_event_id UUID`. Lucee отправит строку, PostgreSQL неявно приведёт к UUID — это работает, но неправильно. При включённом строгом режиме или нестандартном JDBC-драйвере может дать ошибку. Стоит использовать `cfsqltype="cf_sql_char"` с `maxlength="36"` или более явный тип. + +--- + +### Упущенные проблемы + +**8. SQL-инъекция в `_lucee_query` — КРИТИЧНО** + +convert_server.py: Метод `_lucee_query` принимает готовую SQL-строку, которую формируют через f-string интерполяцию. Например: +```python +f"... WHERE s.contract_id='{cid}' ..." +``` +`cid` приходит из GET-параметра `?contract_id=...` без какой-либо санитизации. Если атакующий передаст `cid = "'; DROP TABLE spec_current; --"` — запрос выполнится. + +**Рекомендация:** Передавать `contract_id` как параметр через `params` API Lucee, а не конкатенацией в строку. Либо хотя бы проверять `cid` по regex UUID: `re.fullmatch(r'[0-9a-f-]{36}', cid)`. + +--- + +**9. `elements_json` double-decode — баг надёжности** + +convert_server.py: +```python +ej = docs[0]["elements_json"] +if isinstance(ej, dict) and "Value" in ej: + ej = ej["Value"] +elements = json.loads(ej) +``` +Если Lucee вернул `elements_json` уже как распарсенный dict (без `"Value"` обёртки), то `json.loads(dict)` упадёт с `TypeError`. Условие `isinstance(ej, dict)` обрабатывает только Lucee-обёртку, но если `ej` уже список — код падает. + +**Рекомендация:** `if isinstance(ej, (dict, list)): elements = ej if isinstance(ej, list) else json.loads(ej["Value"])`. + +--- + +**10. `full_replace` DELETE не в правильном порядке в аудит-логе** + +apply_events.cfm: DELETE-события для old строк записываются с `seq++` начиная с 0, затем ADD-события из `ops` продолжают seq. Это корректно. НО: если replay этих событий — сначала DELETE всего, потом ADD новых. Проблема: `UNIQUE(contract_id, seq)` не гарантирует порядок применения при replay, если seq не строго монотонный из-за прошлых partial-операций. Нет механизма replay/восстановления из событий. + +**Рекомендация:** Либо добавить флаг `is_replayable` в архитектурный документ (явно задокументировать что replay не поддерживается), либо добавить `applied_at TIMESTAMPTZ` и `parent_seq` для правильного replay. + +--- + +**11. LLM-ключ в открытом виде в коде** + +convert_server.py: `LLM_KEY = "sk-ucI5YvOticoOQ9Kuj5K9mQ"` — hardcoded в файле. При любом git push ключ попадёт в историю. + +**Рекомендация:** Перенести в переменную окружения `os.environ.get("LLM_KEY")`. + +--- + +**12. Нет идемпотентности — повторный вызов дублирует данные** + +Нет проверки что `supplement_id` уже обработан. Если `/process-v2` вызвать дважды для одного контракта — все operations применятся повторно: ADD-строки задублируются (или упадут на `UNIQUE name_hash`), seq увеличится. + +**Рекомендация:** Добавить `status` колонку в `supplements` (`processed` / `pending`) и проверять в начале цикла. Либо проверять `SELECT COUNT(*) FROM spec_events WHERE supplement_id = ?` перед обработкой. + +--- + +### Итоговая таблица приоритетов + +| # | Проблема | Критичность | +|---|----------|-------------| +| 8 | SQL-инъекция в `_lucee_query` | 🔴 Критично | +| 1 | Race condition MAX(seq) | 🔴 Критично | +| 2 | full_replace + empty ops → потеря данных | 🔴 Критично | +| 12 | Нет идемпотентности | 🟠 Высокая | +| 6 | name_hash нестабилен | 🟠 Высокая | +| 3 | LLM строка вместо числа → crash | 🟠 Высокая | +| 11 | LLM-ключ в коде | 🟡 Средняя | +| 9 | elements_json double-decode | 🟡 Средняя | +| 4 | N+1 SELECT в UPDATE | 🟡 Средняя | +| 5 | Построчный INSERT в full_replace | 🟢 Низкая | +| 7 | UUID как cf_sql_varchar | 🟢 Низкая | +| 10 | Нет механизма replay | 🟢 Низкая | \ No newline at end of file