md
This commit is contained in:
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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 колонки с `<cfif len(...)>`
|
||||||
|
|
||||||
|
## Что НЕ изменилось
|
||||||
|
- Логика сравнения
|
||||||
|
- spec_current
|
||||||
|
- UI
|
||||||
|
- Старые строки — NULL в новых колонках, ни на что не влияет
|
||||||
|
|
||||||
|
## Риски
|
||||||
|
- Нулевые. Колонки опциональные (NULL разрешён), чисто аддитивные.
|
||||||
@@ -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 иконки
|
||||||
@@ -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
|
||||||
Submodule
+1
Submodule contractor added at ec842abb92
@@ -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: `<cfqueryparam value="#evt.id#" cfsqltype="cf_sql_varchar">` для колонки `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 | 🟢 Низкая |
|
||||||
Reference in New Issue
Block a user