Compare commits
3
Commits
e5c7c88073
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83002b9644 | ||
|
|
f9745e5c6b | ||
|
|
811be85efe |
@@ -0,0 +1,81 @@
|
|||||||
|
# Ответ Соннета: code review pipeline сверки
|
||||||
|
|
||||||
|
Дата получения: 2026-08-27
|
||||||
|
Источник: пользовательский attachment `Pasted text #1`
|
||||||
|
Статус: внешний отчёт, не подтверждённый текущим агентом
|
||||||
|
|
||||||
|
## Область
|
||||||
|
|
||||||
|
Соннет анализировал pipeline собственно сверки: `process.py`, `pipeline_bp.py`, `compare.js`, `app.js`, `llm.py`, `llm_client.py`, `llm_prompt.py`, `spec_events.py`, `spec_current.py`, `supplements.py`, `connection.py`, `metrics.py` и связанные тесты. Upload, ZIP, парсинг, классификация и grouping заявлены как исключённые из scope.
|
||||||
|
|
||||||
|
## Заявленные findings
|
||||||
|
|
||||||
|
### BLOCKER
|
||||||
|
|
||||||
|
- **B-1:** backend выдаёт событие `complete`, frontend обрабатывает только `done`; успешная сверка отображается как ошибка соединения.
|
||||||
|
- **B-2:** `apply_ops()` якобы не имеет единой транзакции; при сбое возможен частичный commit и недостоверная summary.
|
||||||
|
- **B-3:** UPDATE поля `name` якобы не пересчитывает `name_hash`, что может привести к дублированию строки при следующем документе.
|
||||||
|
|
||||||
|
### HIGH
|
||||||
|
|
||||||
|
- **H-1:** ветка неизвестного action якобы не увеличивает `seq`.
|
||||||
|
- **H-2:** summary `apply_ops()` якобы не содержит `unresolved`, поэтому счётчик недоступен UI.
|
||||||
|
- **H-3:** результат `check_arithmetic()` игнорируется; арифметическая ошибка не попадает в SSE/UI.
|
||||||
|
- **H-4:** `full_replace` с пустым `ops` якобы сначала очищает current state и затем успешно применяет ноль операций.
|
||||||
|
|
||||||
|
### MEDIUM
|
||||||
|
|
||||||
|
- **M-1:** pipeline безусловно завершает работу событием `complete`, даже после ошибок всех документов.
|
||||||
|
- **M-2:** отсутствует блокировка двух одновременных сравнений одного `contract_id`.
|
||||||
|
- **M-3:** отсутствует клиентский timeout SSE.
|
||||||
|
- **M-4:** `last_event_id` в `spec_current` якобы никогда не заполняется, поэтому cleanup supplement не работает.
|
||||||
|
|
||||||
|
### LOW
|
||||||
|
|
||||||
|
- **L-1:** сортировка по `doc_date` и `created_at` в `process.py` использует поля, которые якобы не возвращаются `supplements.list_by_contract()`.
|
||||||
|
- **L-2:** пустой `current_spec` не различает штатный первый документ и состояние после ошибки.
|
||||||
|
|
||||||
|
## Ответы Соннета на обязательные вопросы
|
||||||
|
|
||||||
|
1. Допник без базового договора не фильтруется; при пустом current state получает extract prompt и может быть ошибочно обработан как базовый документ.
|
||||||
|
2. `run_pipeline()` считает сверку успешной при наличии хотя бы одного supplement и безусловно выдаёт финальное событие.
|
||||||
|
3. Да, финальное событие может прийти после `extract_error`, включая случай, когда ошиблись все документы.
|
||||||
|
4. `complete` против `done` объявлено реальным frontend/backend багом.
|
||||||
|
5. Пустой `full_replace` очищает спецификацию; это признано опасным.
|
||||||
|
6. Частичный `apply_ops()` якобы остаётся в БД, но UI получает `extract_error` и неверную нулевую summary.
|
||||||
|
7. Единой транзакции current state и event history, по мнению Соннета, нет.
|
||||||
|
8. Повторный запуск сначала очищает состояние; последовательный запуск может дать чистый результат, конкурентный опасен.
|
||||||
|
9. Конкурентные pipeline для одного договора создают race condition и риск коллизий `seq`.
|
||||||
|
10. Пустой корректный ответ и повреждённый/неполный ответ не различаются.
|
||||||
|
11. `UNRESOLVED` сохраняется в `spec_events`, но не отображается пользователю и не включается в summary.
|
||||||
|
12. Арифметическая ошибка может остаться в БД, а pipeline всё равно завершиться успешно.
|
||||||
|
13. Нужны тесты для финального события, rollback, смены name, пустого full_replace, unknown action, unresolved summary, arithmetic warning и concurrency.
|
||||||
|
14. Главные риски: частичные commits, дублирование после смены name, уничтожение состояния при пустом full_replace, ложная ошибка UI и отсутствие защиты от concurrency.
|
||||||
|
|
||||||
|
## Предложенный Соннетом порядок исправлений
|
||||||
|
|
||||||
|
1. `complete` -> `done`.
|
||||||
|
2. Пересчёт `name_hash` при изменении `name`/`date_start`.
|
||||||
|
3. Запрет пустого `full_replace` до очистки state.
|
||||||
|
4. Единая транзакция для `apply_ops()`.
|
||||||
|
5. Инкремент `seq` для неизвестного action.
|
||||||
|
6. Счётчик `unresolved` и `total_unresolved`.
|
||||||
|
7. SSE warning для arithmetic mismatch.
|
||||||
|
8. `had_errors` в финальном событии.
|
||||||
|
9. Заполнение `last_event_id`.
|
||||||
|
|
||||||
|
## Что не является подтверждённым фактом
|
||||||
|
|
||||||
|
Этот файл фиксирует именно ответ Соннета. Ни один finding здесь не следует считать основанием для изменения кода до повторной проверки:
|
||||||
|
|
||||||
|
- по исходникам;
|
||||||
|
- по фактической схеме БД и реализации connection layer;
|
||||||
|
- по тестам;
|
||||||
|
- по реальному frontend/backend event contract;
|
||||||
|
- по воспроизводимому сценарию.
|
||||||
|
|
||||||
|
Следующий этап: критическая повторная проверка каждого finding и уточнение вопросов Соннету только там, где в его отчёте останется неразрешённое противоречие или отсутствует доказательство.
|
||||||
|
|
||||||
|
## Уточнение пользователя
|
||||||
|
|
||||||
|
Соннет используется только для анализа. Он не должен писать код, патчи или diff, изменять файлы либо выполнять команды: это ограничение введено для контроля стоимости. Реализацию и проверки выполняем отдельно после критической перепроверки findings.
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
# Промпт для Соннета: code review только собственно сверки
|
||||||
|
|
||||||
|
Нужно провести строгий code review **только той части системы, которая выполняет собственно сверку документов после формирования группы**.
|
||||||
|
|
||||||
|
Не анализируй и не ревьюируй upload, WebDAV/VM upload, ZIP-распаковку, выбор папки, дедупликацию файлов, парсинг DOC/DOCX, garbage-фильтры, классификацию документов, определение типа документа, matching/grouping документов и UI загрузки. Эти части находятся вне области данного ревью.
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Система получает уже сформированную группу договора и связанных документов. Затем она должна:
|
||||||
|
|
||||||
|
1. определить порядок документов группы;
|
||||||
|
2. получить текущую спецификацию;
|
||||||
|
3. передать текущую спецификацию и текст очередного документа в LLM;
|
||||||
|
4. получить операции `ADD`, `UPDATE`, `DELETE`, `UNRESOLVED` или режим `full_replace`;
|
||||||
|
5. корректно транслировать ссылки LLM на строки текущей спецификации;
|
||||||
|
6. применить операции к БД и сохранить историю событий;
|
||||||
|
7. отдать прогресс и результат через SSE;
|
||||||
|
8. показать пользователю фактический итог сверки.
|
||||||
|
|
||||||
|
Особенно проверь, что система не выдаёт успешный результат, если часть операций или документов фактически не обработана.
|
||||||
|
|
||||||
|
## Файлы для обязательного изучения
|
||||||
|
|
||||||
|
Изучи только эти файлы и их прямые зависимости, необходимые для понимания сверки:
|
||||||
|
|
||||||
|
1. `contracts-flask/site/services/process.py`
|
||||||
|
- основной pipeline сверки;
|
||||||
|
- порядок документов;
|
||||||
|
- получение текущей спецификации;
|
||||||
|
- подготовка текста документа;
|
||||||
|
- вызов LLM;
|
||||||
|
- трансляция `target_id` в `target_hash`;
|
||||||
|
- обработка `full_replace`;
|
||||||
|
- применение операций;
|
||||||
|
- арифметическая проверка;
|
||||||
|
- SSE-события логического pipeline.
|
||||||
|
|
||||||
|
2. `contracts-flask/site/routes/pipeline_bp.py`
|
||||||
|
- endpoint `GET /process-v2`;
|
||||||
|
- генерация SSE;
|
||||||
|
- heartbeat;
|
||||||
|
- закрытие/обрыв соединения;
|
||||||
|
- преобразование исключений в SSE-события;
|
||||||
|
- различие HTTP-ошибки до начала стрима и ошибки внутри стрима.
|
||||||
|
|
||||||
|
3. `contracts-flask/site/static/compare.js`
|
||||||
|
- `startCompareSSE()`;
|
||||||
|
- обработка `extract_start`, `llm_done`, `applied`, `extract_error`, `apply_error`, `done`, `error`;
|
||||||
|
- обработка `EventSource.onerror`;
|
||||||
|
- закрытие EventSource;
|
||||||
|
- соответствие frontend-событий backend-событиям.
|
||||||
|
|
||||||
|
4. `contracts-flask/site/static/app.js`
|
||||||
|
- функции запуска сверки группы;
|
||||||
|
- вызов `/api/apply-groups`;
|
||||||
|
- получение `contract_id`;
|
||||||
|
- запуск `/process-v2`;
|
||||||
|
- обработка success/error/connection error;
|
||||||
|
- изменение состояния группы после завершения.
|
||||||
|
|
||||||
|
5. `contracts-flask/site/services/llm.py`
|
||||||
|
- фактический вызов LLM для сверки;
|
||||||
|
- формат prompt и ответа;
|
||||||
|
- timeout/retry;
|
||||||
|
- обработка невалидного ответа;
|
||||||
|
- возможная потеря или искажение операций.
|
||||||
|
|
||||||
|
6. `contracts-flask/site/llm_prompt.py`
|
||||||
|
- prompt сверки;
|
||||||
|
- формат текущей спецификации;
|
||||||
|
- формат ожидаемых операций;
|
||||||
|
- ограничения для `ADD`, `UPDATE`, `DELETE`, `UNRESOLVED`, `full_replace`.
|
||||||
|
|
||||||
|
7. `contracts-flask/site/db/spec_events.py`
|
||||||
|
- `reset()`;
|
||||||
|
- `clear_current()`;
|
||||||
|
- `apply_ops()`;
|
||||||
|
- транзакции и атомарность;
|
||||||
|
- сохранение истории;
|
||||||
|
- статусы применённых и неразрешённых операций;
|
||||||
|
- поведение при частичной ошибке.
|
||||||
|
|
||||||
|
8. `contracts-flask/site/db/spec_current.py`
|
||||||
|
- получение текущих строк спецификации;
|
||||||
|
- идентификаторы и `name_hash`;
|
||||||
|
- получение `elements_json`;
|
||||||
|
- согласованность данных между текущим состоянием и историей.
|
||||||
|
|
||||||
|
9. `contracts-flask/site/db/supplements.py`
|
||||||
|
- только функции, используемые `process.py` для получения документов уже сформированной группы;
|
||||||
|
- порядок и фильтрация документов;
|
||||||
|
- отсутствие/дублирование документов.
|
||||||
|
|
||||||
|
10. `contracts-flask/site/services/metrics.py`
|
||||||
|
- `check_arithmetic()`;
|
||||||
|
- что именно проверяется;
|
||||||
|
- может ли ошибка арифметики повлиять на результат;
|
||||||
|
- почему проверка не должна маскировать ошибку применения.
|
||||||
|
|
||||||
|
## Тесты для обязательного изучения
|
||||||
|
|
||||||
|
1. `contracts-flask/tests/test_process_pipeline.py`
|
||||||
|
- какие сценарии реально покрыты;
|
||||||
|
- корректность `full_replace`;
|
||||||
|
- корректность трансляции `target_id`;
|
||||||
|
- отсутствие тестов на реальные SSE и ошибки LLM.
|
||||||
|
|
||||||
|
2. `contracts-flask/tests/test_grouping.py`
|
||||||
|
- только часть, необходимая для понимания структуры группы, передаваемой в `apply_groups`.
|
||||||
|
|
||||||
|
3. `contracts-flask/tests/test_metrics.py`
|
||||||
|
- только тесты арифметической проверки, относящиеся к операциям сверки.
|
||||||
|
|
||||||
|
4. Найди все остальные тесты, которые напрямую вызывают `run_pipeline`, `apply_ops`, `process-v2` или `startCompareSSE`, и включи их в анализ только если они действительно относятся к собственно сверке.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
|
||||||
|
### 1. Корректность алгоритма сверки
|
||||||
|
|
||||||
|
- Не теряется ли первая спецификация или базовое состояние.
|
||||||
|
- Правильно ли строится `current_spec` перед каждым следующим документом.
|
||||||
|
- Гарантирован ли правильный порядок обработки документов.
|
||||||
|
- Не зависит ли порядок от нестабильного `created_at` или формата даты.
|
||||||
|
- Корректно ли работает переход между несколькими документами группы.
|
||||||
|
- Не дублируются ли строки при повторном запуске.
|
||||||
|
- Корректно ли работает `full_replace` при уже существующих строках.
|
||||||
|
- Может ли `full_replace` удалить корректные данные при ошибочном/неполном ответе LLM.
|
||||||
|
|
||||||
|
### 2. Операции LLM
|
||||||
|
|
||||||
|
- Как `target_id` переводится в фактический идентификатор строки.
|
||||||
|
- Что происходит при `r0`, `r999`, `rX`, отсутствующем `target_id`, неверном `target_hash`.
|
||||||
|
- Не может ли LLM обновить не ту строку из-за изменения порядка строк.
|
||||||
|
- Что происходит с неизвестными действиями.
|
||||||
|
- Что происходит с отсутствующими полями `name`, `price`, `qty`, `sum`, `date_start`.
|
||||||
|
- Не приводит ли частично валидная операция к тихой потере данных.
|
||||||
|
- Сохраняется ли исходный ответ LLM и prompt для аудита.
|
||||||
|
- Как обрабатываются пустой, обрезанный, markdown-обёрнутый или частично повреждённый JSON-ответ.
|
||||||
|
|
||||||
|
### 3. БД, транзакции и атомарность
|
||||||
|
|
||||||
|
- Атомарно ли применяются операции одного документа.
|
||||||
|
- Что происходит, если пятая операция из десяти падает.
|
||||||
|
- Может ли current state измениться, а event history не сохраниться, или наоборот.
|
||||||
|
- Не приводит ли `reset()` к потере истории при повторном запуске.
|
||||||
|
- Различаются ли `applied`, `unresolved`, `failed` и действительно ли эти статусы отражают результат.
|
||||||
|
- Безопасен ли параллельный запуск двух сравнений одного договора.
|
||||||
|
- Безопасен ли одновременный запуск сравнений разных групп.
|
||||||
|
- Есть ли race condition между `apply-groups`, `/process-v2` и frontend state.
|
||||||
|
|
||||||
|
### 4. SSE и сетевые ошибки
|
||||||
|
|
||||||
|
- Совпадает ли событие завершения backend (`complete` или `done`) с событием, которое ожидает frontend.
|
||||||
|
- Может ли frontend навсегда оставить сравнение в состоянии `⏳`.
|
||||||
|
- Что происходит при disconnect после `applied`, но до события завершения.
|
||||||
|
- Что происходит при heartbeat без данных.
|
||||||
|
- Может ли `EventSource.onerror` ошибочно объявить ошибку после нормального закрытия.
|
||||||
|
- Показывает ли UI частичный результат как полный.
|
||||||
|
- Есть ли таймаут на клиенте и сервере.
|
||||||
|
- Возвращается ли пользователю причина ошибки LLM/API, а не только «Ошибка соединения».
|
||||||
|
- Не теряются ли последние SSE-события из-за proxy buffering.
|
||||||
|
|
||||||
|
### 5. Числовая и предметная корректность
|
||||||
|
|
||||||
|
- Проверяется ли арифметика `price * qty = sum` до применения и после применения.
|
||||||
|
- Как обрабатываются `None`, строки с запятой, целые/дробные числа, отрицательные значения и большие числа.
|
||||||
|
- Не изменяет ли арифметическая проверка данные или только логирует ошибку.
|
||||||
|
- Может ли LLM вернуть арифметически неверную операцию, которая всё равно попадёт в current state.
|
||||||
|
- Сохраняется ли точность Decimal/float.
|
||||||
|
- Как обрабатываются даты и отсутствие даты.
|
||||||
|
|
||||||
|
### 6. Повторяемость и идемпотентность
|
||||||
|
|
||||||
|
- Что произойдёт при повторном нажатии «Сравнить эту группу».
|
||||||
|
- Можно ли безопасно повторить сравнение после сетевого обрыва.
|
||||||
|
- Будут ли повторно добавлены те же строки.
|
||||||
|
- Как отделяется новый запуск от предыдущей истории.
|
||||||
|
- Есть ли идентификатор запуска и защита от повторного применения одного ответа.
|
||||||
|
|
||||||
|
## Обязательные вопросы от ревьюера
|
||||||
|
|
||||||
|
Ответь отдельно на следующие вопросы, даже если для ответа придётся изучить прямую зависимость:
|
||||||
|
|
||||||
|
1. Почему в реальном тесте один допник смог попасть в группу без базового договора, и может ли собственно pipeline сверки безопасно обработать такую неполную группу?
|
||||||
|
2. При каком именно условии `run_pipeline()` считает сверку успешной?
|
||||||
|
3. Может ли pipeline отправить `complete`, если один документ дал `extract_error` или часть операций не применилась?
|
||||||
|
4. Почему backend использует событие `complete`, а frontend-код может ожидать `done`? Это реальный баг или только устаревший комментарий/другая ветка?
|
||||||
|
5. Если LLM вернул `full_replace` с пустым `ops`, будет ли текущая спецификация очищена? Должно ли так происходить?
|
||||||
|
6. Если `apply_ops()` применил только часть операций, как это отражается в SSE и UI?
|
||||||
|
7. Есть ли транзакция, гарантирующая согласованность `spec_current` и `spec_events`?
|
||||||
|
8. Можно ли повторно запустить `/process-v2` для того же `contract_id` без дублирования или повреждения результата?
|
||||||
|
9. Что происходит при одновременном сравнении двух групп, относящихся к одному договору?
|
||||||
|
10. Как система отличает «LLM вернул корректный пустой результат» от «LLM вернул повреждённый/неполный ответ»?
|
||||||
|
11. Какие операции считаются `UNRESOLVED`, где они сохраняются и видит ли их пользователь?
|
||||||
|
12. Может ли `check_arithmetic()` обнаружить ошибку, но pipeline всё равно завершиться успешно?
|
||||||
|
13. Какой минимальный набор интеграционных тестов нужен для доказательства корректности реальной сверки?
|
||||||
|
14. Какие риски остаются именно в собственно сверке после исключения upload/classify/grouping из области анализа?
|
||||||
|
|
||||||
|
## Формат отчёта
|
||||||
|
|
||||||
|
Пиши отчёт в формате code review.
|
||||||
|
|
||||||
|
Сначала findings, отсортированные по серьёзности:
|
||||||
|
|
||||||
|
- `BLOCKER` — возможна потеря/порча данных или ложный успешный результат сверки;
|
||||||
|
- `HIGH` — неверное применение операций, нарушение атомарности, повторное применение или потеря результата;
|
||||||
|
- `MEDIUM` — ошибочное отображение статуса, частичный результат, нестабильность или отсутствие важной защиты;
|
||||||
|
- `LOW` — локальная проблема качества, диагностики или сопровождаемости.
|
||||||
|
|
||||||
|
Для каждого finding укажи:
|
||||||
|
|
||||||
|
- severity;
|
||||||
|
- файл и конкретный символ/участок кода;
|
||||||
|
- точную последовательность, которая приводит к проблеме;
|
||||||
|
- воспроизводимый сценарий;
|
||||||
|
- фактический ущерб;
|
||||||
|
- минимальное исправление;
|
||||||
|
- обязательный тест.
|
||||||
|
|
||||||
|
Не предлагай изменения вне области собственно сверки. Не исправляй код самостоятельно. Не делай общий обзор всего проекта. Если findings нет, напиши это явно и перечисли оставшиеся пробелы тестирования.
|
||||||
|
|
||||||
|
## Жёсткое ограничение по стоимости
|
||||||
|
|
||||||
|
Не пиши код, патчи, diff и готовые реализации. Не изменяй файлы и не выполняй команды. Твоя задача — только code review: факты, findings, доказательства, вопросы и минимальные рекомендации словами. Любые предлагаемые исправления описывай концептуально, без реализации.
|
||||||
|
|
||||||
|
В конце добавь:
|
||||||
|
|
||||||
|
1. ответы на 14 обязательных вопросов;
|
||||||
|
2. таблицу покрытия тестами;
|
||||||
|
3. минимальный план исправлений, если они нужны;
|
||||||
|
4. список файлов, которые действительно были изучены.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Критическая перепроверка ответа Соннета: pipeline сверки
|
||||||
|
|
||||||
|
Дата: 2026-08-27
|
||||||
|
Основание: ответ Соннета из `History/sonnet-review-comparison-answer-2026-08-27.md`
|
||||||
|
|
||||||
|
## Подтверждено по исходникам
|
||||||
|
|
||||||
|
### B-1: `complete` против `done`
|
||||||
|
|
||||||
|
Подтверждено.
|
||||||
|
|
||||||
|
- `site/services/process.py` завершает `run_pipeline()` событием `{"type": "complete"}`.
|
||||||
|
- `site/static/compare.js` завершает успешный SSE только в ветке `d.type === 'done'`.
|
||||||
|
- При нормальном закрытии генератора после неизвестного события клиент получает `EventSource.onerror`, поэтому успешная сверка может отображаться как ошибка соединения.
|
||||||
|
|
||||||
|
Это не предположение Соннета, а прямое несоответствие backend/frontend event contract.
|
||||||
|
|
||||||
|
### B-2: отдельные commits в `apply_ops()`
|
||||||
|
|
||||||
|
Основная часть finding подтверждена.
|
||||||
|
|
||||||
|
- `site/db/spec_events.py::apply_ops()` вызывает `execute()` для каждого события и изменения current state.
|
||||||
|
- `site/db/connection.py::execute()` делает `conn.commit()` после каждого SQL-вызова.
|
||||||
|
- В `apply_ops()` нет единой транзакции и rollback.
|
||||||
|
- Поэтому исключение после уже выполненных операций оставляет предыдущие commits в БД.
|
||||||
|
- `process.py` после исключения формирует нулевую summary и не показывает уже применённые операции как applied.
|
||||||
|
|
||||||
|
Требует отдельной проверки формулировка о том, что history и current state обязательно расходятся при каждом сценарии: это зависит от того, на каком именно SQL-вызове возникает исключение. Но частичный commit и недостоверная summary подтверждены.
|
||||||
|
|
||||||
|
### B-3: stale `name_hash`
|
||||||
|
|
||||||
|
Подтверждено.
|
||||||
|
|
||||||
|
`site/db/spec_events.py::_update_spec_current()` обновляет `name`, `price`, `qty`, `sum`, `date_start`, но не пересчитывает и не обновляет `name_hash`. При последующем ADD с новым именем `_hash()` создаёт другой ключ, поэтому сценарий с дублированием реален.
|
||||||
|
|
||||||
|
Нужно отдельно проверить бизнес-правило для изменения `date_start`: изменение даты может означать новый период и не во всех случаях должно менять identity строки. Автоматически объединять `name` и `date_start` в одно исправление нельзя без подтверждения модели идентичности.
|
||||||
|
|
||||||
|
### H-1: `seq` для неизвестного action
|
||||||
|
|
||||||
|
Подтверждено.
|
||||||
|
|
||||||
|
В ветке `else` `apply_ops()` вызывает `_log_unresolved(...)`, но не делает `seq += 1`. Следующая операция получает тот же `seq`.
|
||||||
|
|
||||||
|
### H-2: `unresolved` недоступен в summary
|
||||||
|
|
||||||
|
Подтверждено по текущим участкам.
|
||||||
|
|
||||||
|
`apply_ops()` возвращает только `added`, `updated`, `deleted`. При этом frontend использует `d.total_unresolved`, а `run_pipeline()` не формирует это поле в финальном событии.
|
||||||
|
|
||||||
|
Требует проверки полный путь отображения `UNRESOLVED`: факт отсутствия счётчика в summary подтверждён, но следует проверить, нет ли другого endpoint/UI, который показывает события напрямую.
|
||||||
|
|
||||||
|
### H-3: результат `check_arithmetic()` игнорируется
|
||||||
|
|
||||||
|
Подтверждено для `run_pipeline()`.
|
||||||
|
|
||||||
|
`process.py` вызывает `check_arithmetic(ops)` и игнорирует возвращаемое значение. Требуется дополнительно проверить, логирует ли сама функция несоответствия и является ли её контракт предупреждением или валидатором, прежде чем выбирать severity и формат исправления.
|
||||||
|
|
||||||
|
### H-4: пустой `full_replace`
|
||||||
|
|
||||||
|
Подтверждено по порядку операций.
|
||||||
|
|
||||||
|
В `process.py` `clear_current(contract_id)` вызывается после получения `mode` и до `apply_ops()`, без проверки непустого `ops`. Пустой список при `mode == 'full_replace'` очищает current state.
|
||||||
|
|
||||||
|
Нужно уточнить у Соннета, какие именно ответы считать повреждёнными: пустой `ops` может быть штатным ответом для пустой спецификации, хотя для существующего current state это опасный случай.
|
||||||
|
|
||||||
|
### M-1: финальное событие после ошибок
|
||||||
|
|
||||||
|
Подтверждено.
|
||||||
|
|
||||||
|
После цикла `run_pipeline()` безусловно выдаёт финальное событие, даже если каждый supplement завершился `extract_error`.
|
||||||
|
|
||||||
|
## Подтверждено частично или требует дополнительных доказательств
|
||||||
|
|
||||||
|
### M-2: concurrency
|
||||||
|
|
||||||
|
Риск правдоподобен, но формулировку о конкретных коллизиях `seq` нужно доказать тестом. `WAL` сериализует записи, но `get_next_seq()` и последующие операции разделены во времени. Нужен воспроизводимый конкурентный тест с двумя pipeline на одном `contract_id`.
|
||||||
|
|
||||||
|
### M-3: SSE timeout
|
||||||
|
|
||||||
|
Отсутствие клиентского timeout видно, но само по себе не доказывает пользовательский дефект: сервер отправляет heartbeat каждые 15 секунд. Нужно проверить proxy timeout, лимит длительности LLM и поведение при остановленном backend.
|
||||||
|
|
||||||
|
### M-4: `last_event_id`
|
||||||
|
|
||||||
|
Подтверждена причина риска: `spec_current` вставляется без `last_event_id`, а UPDATE также его не заполняет; cleanup в `supplements.delete_by_document()` фильтрует по этому полю. Нужен тест удаления supplement после сверки, чтобы окончательно подтвердить наблюдаемое поведение.
|
||||||
|
|
||||||
|
### L-1: сортировка
|
||||||
|
|
||||||
|
Подтверждено, что `list_by_contract()` не выбирает `doc_date` и `created_at` как поля результата, поэтому ключ сортировки в `process.py` фактически использует значения по умолчанию. При этом SQL уже сортирует по `s.created_at`, поэтому это скорее misleading code, а не доказанная поломка порядка.
|
||||||
|
|
||||||
|
### L-2: пустой current state
|
||||||
|
|
||||||
|
Технически возможно, но вывод о неправильном prompt зависит от семантики группы и контракта `build_prompt()`. Нужна точная проверка prompt и отдельный сценарий сбоя/неполной группы.
|
||||||
|
|
||||||
|
## Дополнительные вопросы Соннету
|
||||||
|
|
||||||
|
1. Для B-2: укажи точный SQL-вызов и сценарий исключения, при котором расходятся `spec_events` и `spec_current`; отдельно различи частичный commit и рассогласование двух таблиц.
|
||||||
|
2. Для B-3: должна ли смена `name` менять identity строки, или `name_hash` является историческим ключом? Какие правила действуют при одновременной смене `name` и `date_start`?
|
||||||
|
3. Для H-2: где именно пользователь должен видеть `UNRESOLVED`, если не через summary? Укажи полный frontend/backend путь и проверяемый сценарий.
|
||||||
|
4. Для H-3: что возвращает `check_arithmetic()` и есть ли у него побочный logging? Приведи фактический пример mismatch и ожидаемый бизнес-статус.
|
||||||
|
5. Для H-4: почему пустой `full_replace` однозначно считать повреждённым ответом, если документ может содержать пустую спецификацию? Как отличить штатный результат от обрыва/невалидного JSON?
|
||||||
|
6. Для M-2: предоставь воспроизводимый тест или timeline с двумя потоками, который приводит к одинаковому `seq` или повреждённому current state.
|
||||||
|
7. Для M-3: какой фактический timeout установлен на nginx/proxy и как он соотносится с heartbeat и максимальным временем обработки группы?
|
||||||
|
8. Для M-4: есть ли штатный путь удаления supplement после сверки, и должен ли он удалять строки current state, если строка затронута несколькими supplements?
|
||||||
|
9. Для L-2: приведи точный контракт `extract`/`diff` prompt и доказательство, что пустой current state после ошибки действительно меняет смысл следующего LLM-вызова.
|
||||||
|
10. Какие findings Соннет считает подтверждёнными тестом, а какие являются только статическим риском?
|
||||||
|
11. Проверь финальное событие по реальному frontend-коду: нет ли другой ветки, которая обрабатывает `complete` или завершение `EventSource` без `done`?
|
||||||
|
12. Для каждого BLOCKER укажи минимальный regression test, который сначала падает на текущей версии и проходит после исправления.
|
||||||
|
|
||||||
|
## Предварительный вывод
|
||||||
|
|
||||||
|
Без дополнительных ответов Соннета уже достаточно доказательств для регистрации B-1, B-2, B-3, H-1, H-2, H-3, H-4 и M-1 как реальных проблем текущего кода. M-2, M-3, M-4 и L-2 требуют воспроизводимых тестов или более точной трассировки. L-1 подтверждён как избыточная/вводящая в заблуждение сортировка, но не как текущая поломка порядка документов.
|
||||||
|
|
||||||
|
Изменения production-кода по этим findings не выполнялись.
|
||||||
|
|
||||||
|
## Обязательное ограничение для дальнейшего общения с Соннетом
|
||||||
|
|
||||||
|
Соннет не должен писать код, patch или diff и не должен изменять файлы. Нужно запрашивать только критический анализ, проверяемые доказательства, воспроизводимые сценарии, тестовые идеи в виде описания и дополнительные вопросы. Реализацию выполняем отдельно после собственной проверки findings.
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Как работает сервис «Сверка договоров»
|
||||||
|
|
||||||
|
Документ описывает текущую программную реализацию `contracts-flask`: последовательность обработки данных, ответственность модулей и ключевые функции. Основной актуальный код находится в `site/`; каталог `deploy/` является историческим VM-слоем.
|
||||||
|
|
||||||
|
## 1. Общий поток
|
||||||
|
|
||||||
|
```text
|
||||||
|
Браузер
|
||||||
|
-> выбор файлов / ZIP
|
||||||
|
-> загрузка через VM-буфер
|
||||||
|
-> парсинг
|
||||||
|
-> классификация документов
|
||||||
|
-> группировка по договорам
|
||||||
|
-> применение подтверждённых групп
|
||||||
|
-> SSE-сравнение документов по порядку
|
||||||
|
-> event sourcing: spec_events + spec_current
|
||||||
|
-> таблица изменений и чат по текущей спецификации
|
||||||
|
```
|
||||||
|
|
||||||
|
Главная точка сборки Flask-приложения: [`site/app.py`](../site/app.py). Регистрация маршрутов выполняется через [`site/routes/__init__.py`](../site/routes/__init__.py).
|
||||||
|
|
||||||
|
## 2. Загрузка файлов
|
||||||
|
|
||||||
|
Фронтенд начинает обработку в [`site/static/files.js`](../site/static/files.js):
|
||||||
|
|
||||||
|
- `onFilesSelected()` принимает выбранные файлы, обрабатывает ZIP и обычные документы, запускает подготовку загрузки и сохраняет связь `zip_source`.
|
||||||
|
- `uploadFile()` запускает загрузку через VM-буфер; фактические PUT и отправка ссылок выполняются функциями `uploadViaVM()` и `putToVm()` в [`upload/frontend/upload/upload_via_vm.js`](../upload/frontend/upload/upload_via_vm.js) и [`upload/frontend/upload/put_to_vm.js`](../upload/frontend/upload/put_to_vm.js).
|
||||||
|
- `finalizeUpload()` завершает загрузку и обновляет состояние.
|
||||||
|
- `renderFiles(state)` отображает список файлов, группируя вложения по `zip_source`.
|
||||||
|
- `syncDB()` синхронизирует состав файлов в БД.
|
||||||
|
|
||||||
|
Из-за ограничения managed-шлюза большие файлы сначала передаются на ВМ, а затем бэкенд забирает их исходящим запросом. Общий переиспользуемый транспорт находится в [`upload/backend/upload_refs/blueprint.py`](../upload/backend/upload_refs/blueprint.py), включая обработчик `POST /api/upload_refs`; конкретная бизнес-логика передаётся через `sink`.
|
||||||
|
|
||||||
|
Маршруты загрузки основного приложения находятся в [`site/routes/upload_bp.py`](../site/routes/upload_bp.py). Там принимаются ссылки/файлы, вызывается sink сервиса договоров и сохраняются результаты обработки.
|
||||||
|
|
||||||
|
## 3. Парсинг
|
||||||
|
|
||||||
|
Парсер находится в [`site/services/parse.py`](../site/services/parse.py). Он преобразует содержимое документов в структурированный список элементов:
|
||||||
|
|
||||||
|
- `parse()` выбирает обработчик по формату.
|
||||||
|
- Обработчик DOCX использует `python-docx` и извлекает параграфы и таблицы.
|
||||||
|
- Обработчик PDF использует `pdfplumber`.
|
||||||
|
- TXT обрабатывается напрямую.
|
||||||
|
- Для `.doc` функция `parse_file()` в текущем `site` вызывает `_parse_docx(data)`; отдельный [`convert-service/app.py`](../convert-service/app.py) и исторический [`deploy/convert_doc.py`](../deploy/convert_doc.py) не являются частью этого вызова.
|
||||||
|
|
||||||
|
Результат парсинга сохраняется как `elements_json`. Важный принцип: парсер не решает, какие строки являются услугами, а сохраняет исходную структуру документа для следующих стадий.
|
||||||
|
|
||||||
|
## 4. Классификация документов
|
||||||
|
|
||||||
|
Маршрут запуска классификации: [`site/routes/pipeline_bp.py`](../site/routes/pipeline_bp.py), функция `classify_batch_route()`.
|
||||||
|
|
||||||
|
- Для небольшого батча классификация выполняется синхронно.
|
||||||
|
- Для большого батча запускается фоновый поток и возвращается `202`.
|
||||||
|
- `process_v2()` отдаёт поток SSE для этапа сравнения.
|
||||||
|
|
||||||
|
Основная логика находится в [`site/services/classify.py`](../site/services/classify.py):
|
||||||
|
|
||||||
|
- `classify_batch(batch_id, llm_client=None, repo=None)` получает pending-документы, запускает параллельную обработку через `ThreadPoolExecutor` и сохраняет результат.
|
||||||
|
- `_classify_one()` выполняет полный цикл для одного файла.
|
||||||
|
- `_is_garbage_by_filename()` отбрасывает очевидный мусор по имени файла.
|
||||||
|
- `_smart_extract()` формирует компактную выжимку: начало документа и найденные фрагменты с ключевыми маркерами.
|
||||||
|
- `_is_garbage_by_header()` отбрасывает документы по заголовочным маркерам.
|
||||||
|
- `_call_llm_classify()` отправляет выжимку в LLM.
|
||||||
|
- `_safe_json_parse()` извлекает JSON из ответа LLM и исправляет распространённые ошибки форматирования.
|
||||||
|
|
||||||
|
Промпт формируется функцией `build_classify_prompt()` в [`site/llm_prompt.py`](../site/llm_prompt.py). Результат содержит `doc_type`, `own_number`, `parent_number`, `doc_date` и `counterparty`. Сырые вход и ответ LLM также сохраняются для диагностики.
|
||||||
|
|
||||||
|
## 5. Группировка по договорам
|
||||||
|
|
||||||
|
Логика находится в [`site/services/grouping.py`](../site/services/grouping.py):
|
||||||
|
|
||||||
|
- `normalize_number(num)` приводит номер к верхнему регистру и убирает разделители.
|
||||||
|
- `group_documents(batch_id)` отделяет договоры от допников/спецификаций, сопоставляет их по `parent_number` или `own_number`, создаёт виртуальные группы при отсутствии базового договора и помещает нерешённые документы в `__unresolved__`.
|
||||||
|
- Внутри групп документы сортируются по `doc_date`.
|
||||||
|
- `apply_groups(batch_id, groups_data)` сохраняет подтверждённые группы в таблицы договоров и допников.
|
||||||
|
|
||||||
|
Маршруты чтения и применения групп находятся в [`site/routes/api_bp.py`](../site/routes/api_bp.py). Фронтенд отображает результат через [`site/static/groups.js`](../site/static/groups.js).
|
||||||
|
|
||||||
|
Таким образом, LLM извлекает признаки документа, но окончательное сопоставление по номерам выполняется обычным Python-кодом.
|
||||||
|
|
||||||
|
## 6. Последовательное сравнение
|
||||||
|
|
||||||
|
Маршрут сравнения: `process_v2()` в [`site/routes/pipeline_bp.py`](../site/routes/pipeline_bp.py). Он проверяет `contract_id`, запускает `run_pipeline()` и преобразует события в формат SSE.
|
||||||
|
|
||||||
|
Основной pipeline находится в [`site/services/process.py`](../site/services/process.py):
|
||||||
|
|
||||||
|
- `run_pipeline(contract_id, order_ids, build_prompt_fn)` сбрасывает предыдущее состояние, получает документы группы и определяет порядок обработки.
|
||||||
|
- `_elements_to_text(ej)` преобразует `elements_json` в текстовый контекст для LLM.
|
||||||
|
- Для каждого документа читается текущая спецификация.
|
||||||
|
- `call_llm()` из [`site/services/llm.py`](../site/services/llm.py) получает текущие строки и текст документа и возвращает операции.
|
||||||
|
- `target_id` вида `r1`, `r2` переводится в `target_hash` соответствующей строки текущей спецификации.
|
||||||
|
- Для режима `full_replace` вызывается `clear_current()`, чтобы новая редакция не дублировала старые строки.
|
||||||
|
- Затем вызывается `apply_ops()` и отправляются SSE-события `extract_start`, `llm_done`, `applied`, `extract_error` и финальное `complete`.
|
||||||
|
- `check_arithmetic()` из [`site/services/metrics.py`](../site/services/metrics.py) проверяет согласованность `sum`, `price` и `qty`.
|
||||||
|
|
||||||
|
Промпты извлечения и сравнения формируются через `build_prompt()` в [`site/llm_prompt.py`](../site/llm_prompt.py); версии промптов хранятся и редактируются маршрутами из [`site/routes/prompts_bp.py`](../site/routes/prompts_bp.py).
|
||||||
|
|
||||||
|
## 7. Event sourcing и текущее состояние
|
||||||
|
|
||||||
|
Механизм хранения находится в [`site/db/spec_events.py`](../site/db/spec_events.py):
|
||||||
|
|
||||||
|
- `reset(contract_id)` очищает события и текущее состояние перед новым полным прогоном.
|
||||||
|
- `clear_current(contract_id)` очищает только текущую спецификацию для `full_replace`.
|
||||||
|
- `apply_ops(...)` обрабатывает `ADD`, `UPDATE`, `DELETE` и `UNRESOLVED`.
|
||||||
|
- `_hash(name, date_start)` создаёт стабильный идентификатор строки услуги.
|
||||||
|
- `_upsert_spec_current(...)` добавляет или обновляет строку текущей спецификации.
|
||||||
|
- `_update_spec_current(...)` изменяет только поля, указанные в операции.
|
||||||
|
- `_log_unresolved(...)` сохраняет нерешённую операцию вместо молчаливого пропуска.
|
||||||
|
|
||||||
|
`spec_events` — журнал операций с исходным ответом LLM, версией промпта и ссылкой на документ. `spec_current` — материализованное состояние, используемое для следующего сравнения и отображения.
|
||||||
|
|
||||||
|
CRUD текущей спецификации и исходных данных документа находится в [`site/db/spec_current.py`](../site/db/spec_current.py). Схема и соединения описаны в [`site/db/connection.py`](../site/db/connection.py).
|
||||||
|
|
||||||
|
## 8. SSE и интерфейс
|
||||||
|
|
||||||
|
Клиент сравнения находится в [`site/static/compare.js`](../site/static/compare.js):
|
||||||
|
|
||||||
|
- `startCompareSSE()` открывает `EventSource`, принимает события и управляет таймером.
|
||||||
|
- `applyCompareEvent()` переводит SSE-события в состояние секций.
|
||||||
|
- `renderCompareSectionHeader()` формирует заголовок секции документа.
|
||||||
|
- `renderCompareSectionBody()` отображает итоговую статистику и операции.
|
||||||
|
- `renderCompareOpsTable()` строит таблицу изменений.
|
||||||
|
|
||||||
|
Оркестрация шагов загрузки, классификации, группировки и запуска сравнения находится в [`site/static/app.js`](../site/static/app.js), в частности в `runClassify()` и обработчиках `loadGroupsAction()`/сравнения.
|
||||||
|
|
||||||
|
## 9. Чат
|
||||||
|
|
||||||
|
Маршрут чата находится в [`site/routes/api_bp.py`](../site/routes/api_bp.py). Он получает вопрос пользователя, загружает текущую спецификацию через функции из [`site/db/spec_current.py`](../site/db/spec_current.py), формирует контекст и отправляет его в LLM. Поэтому чат отвечает по материализованному состоянию договора, а не по случайному отдельному документу.
|
||||||
|
|
||||||
|
## 10. Итоговая ответственность компонентов
|
||||||
|
|
||||||
|
| Слой | Ответственность |
|
||||||
|
|---|---|
|
||||||
|
| `site/static/*.js` | выбор файлов, загрузка, отображение прогресса и результатов |
|
||||||
|
| `site/routes/` | HTTP API, SSE и связывание компонентов |
|
||||||
|
| `site/services/parse.py` | извлечение структуры документа |
|
||||||
|
| `site/services/classify.py` | классификация и выделение метаданных через LLM |
|
||||||
|
| `site/services/grouping.py` | детерминированное сопоставление документов |
|
||||||
|
| `site/services/process.py` | последовательное сравнение группы |
|
||||||
|
| `site/services/llm.py` | вызов LLM для анализа |
|
||||||
|
| `site/db/spec_events.py` | применение операций и аудит изменений |
|
||||||
|
| `site/db/spec_current.py` | чтение текущей спецификации и элементов документов |
|
||||||
|
| `site/db/connection.py` | SQLite/WAL и доступ к данным |
|
||||||
|
|
||||||
|
Итог: LLM используется там, где требуется понять содержание документа и смысл изменения услуги. Структура pipeline, нормализация номеров, порядок, проверки и сохранение результата выполняются детерминированным кодом.
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
"""Конфигурация приложения — все настройки в одном месте."""
|
"""Конфигурация приложения — все настройки в одном месте."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
VERSION = "2.0.20"
|
VERSION = "2.0.21"
|
||||||
|
|
||||||
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
|
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
|
||||||
LLM_KEY = os.getenv("LLM_API_KEY", "")
|
LLM_KEY = os.getenv("LLM_API_KEY", "")
|
||||||
|
|||||||
+19
-1
@@ -10,12 +10,29 @@ import sqlite3
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
DB_PATH = "/tmp/contracts.db"
|
DB_PATH = "/tmp/contracts.db"
|
||||||
_db_session_key = None
|
_db_session_key = None
|
||||||
_local = threading.local()
|
_local = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction():
|
||||||
|
"""Run DB writes atomically on the current thread connection."""
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
_local.in_transaction = True
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
_local.in_transaction = False
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""Создать/пересоздать БД + схему. Вызывается при старте и после cleanup."""
|
"""Создать/пересоздать БД + схему. Вызывается при старте и после cleanup."""
|
||||||
global _db_session_key
|
global _db_session_key
|
||||||
@@ -171,7 +188,8 @@ def execute(sql, params=None):
|
|||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
sql = _pg_to_sqlite(sql)
|
sql = _pg_to_sqlite(sql)
|
||||||
cur = conn.execute(sql, params or [])
|
cur = conn.execute(sql, params or [])
|
||||||
conn.commit()
|
if not getattr(_local, "in_transaction", False):
|
||||||
|
conn.commit()
|
||||||
return cur.rowcount
|
return cur.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+30
-15
@@ -1,6 +1,6 @@
|
|||||||
"""spec_events — event sourcing: apply ops, reset contract."""
|
"""spec_events — event sourcing: apply ops, reset contract."""
|
||||||
import json, uuid
|
import json, uuid
|
||||||
from db.connection import query, execute, get_conn
|
from db.connection import query, execute, get_conn, transaction
|
||||||
|
|
||||||
|
|
||||||
def reset(contract_id):
|
def reset(contract_id):
|
||||||
@@ -27,9 +27,15 @@ def get_next_seq(contract_id):
|
|||||||
|
|
||||||
def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
|
def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
|
||||||
"""Apply ADD/UPDATE/DELETE ops. Returns summary dict."""
|
"""Apply ADD/UPDATE/DELETE ops. Returns summary dict."""
|
||||||
|
with transaction():
|
||||||
|
return _apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
|
||||||
added = 0
|
added = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
deleted = 0
|
deleted = 0
|
||||||
|
unresolved = 0
|
||||||
seq = get_next_seq(contract_id)
|
seq = get_next_seq(contract_id)
|
||||||
|
|
||||||
for op in ops:
|
for op in ops:
|
||||||
@@ -42,6 +48,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
|
|||||||
# ADD without name → UNRESOLVED
|
# ADD without name → UNRESOLVED
|
||||||
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "ADD with empty name")
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "ADD with empty name")
|
||||||
seq += 1
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
continue
|
continue
|
||||||
name_hash = _hash(name, nr.get("date_start"))
|
name_hash = _hash(name, nr.get("date_start"))
|
||||||
execute(
|
execute(
|
||||||
@@ -65,6 +72,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
|
|||||||
if not th:
|
if not th:
|
||||||
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "UPDATE with empty target_hash")
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "UPDATE with empty target_hash")
|
||||||
seq += 1
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
continue
|
continue
|
||||||
execute(
|
execute(
|
||||||
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
||||||
@@ -86,6 +94,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
|
|||||||
if not th:
|
if not th:
|
||||||
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "DELETE with empty target_hash")
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "DELETE with empty target_hash")
|
||||||
seq += 1
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
continue
|
continue
|
||||||
execute(
|
execute(
|
||||||
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
||||||
@@ -104,27 +113,19 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
|
|||||||
|
|
||||||
elif action == "UNRESOLVED":
|
elif action == "UNRESOLVED":
|
||||||
# Log but don't apply
|
# Log but don't apply
|
||||||
execute(
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
|
||||||
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
op.get("reason", op.get("comment", "")))
|
||||||
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
|
||||||
VALUES (%s, %s, %s, %s, 'UNRESOLVED', %s, %s, %s, 'unresolved', %s, %s, %s)""",
|
|
||||||
(
|
|
||||||
str(uuid.uuid4()), contract_id, supplement_id, seq,
|
|
||||||
op.get("target_hash", ""),
|
|
||||||
json.dumps(op.get("new_values", {}), ensure_ascii=False),
|
|
||||||
op.get("reason", op.get("comment", "")),
|
|
||||||
prompt_id, document_id,
|
|
||||||
json.dumps(raw_llm_response, ensure_ascii=False),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
seq += 1
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Unknown action — log as UNRESOLVED
|
# Unknown action — log as UNRESOLVED
|
||||||
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
|
||||||
f"unknown action: {action}")
|
f"unknown action: {action}")
|
||||||
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
|
|
||||||
return {"added": added, "updated": updated, "deleted": deleted}
|
return {"added": added, "updated": updated, "deleted": deleted, "unresolved": unresolved}
|
||||||
|
|
||||||
|
|
||||||
def _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, reason):
|
def _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, reason):
|
||||||
@@ -187,6 +188,20 @@ def _update_spec_current(contract_id, name_hash, new_values):
|
|||||||
sets.append(f"{field} = %s")
|
sets.append(f"{field} = %s")
|
||||||
params.append(new_values[field])
|
params.append(new_values[field])
|
||||||
if sets:
|
if sets:
|
||||||
|
if "name" in new_values or "date_start" in new_values:
|
||||||
|
updated_name = new_values.get("name")
|
||||||
|
updated_date = new_values.get("date_start")
|
||||||
|
if updated_name is None or updated_date is None:
|
||||||
|
current = query(
|
||||||
|
"SELECT name, date_start FROM spec_current WHERE contract_id = %s AND name_hash = %s",
|
||||||
|
(contract_id, name_hash),
|
||||||
|
)
|
||||||
|
if current:
|
||||||
|
updated_name = updated_name if updated_name is not None else current[0]["name"]
|
||||||
|
updated_date = updated_date if updated_date is not None else current[0]["date_start"]
|
||||||
|
if updated_name is not None:
|
||||||
|
sets.append("name_hash = %s")
|
||||||
|
params.append(_hash(updated_name, updated_date))
|
||||||
sets.append("updated_at = datetime('now')")
|
sets.append("updated_at = datetime('now')")
|
||||||
params.extend([contract_id, name_hash])
|
params.extend([contract_id, name_hash])
|
||||||
execute(
|
execute(
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
|||||||
ops = result.get("ops", [])
|
ops = result.get("ops", [])
|
||||||
mode = result.get("mode", "llm")
|
mode = result.get("mode", "llm")
|
||||||
|
|
||||||
|
if mode == "full_replace" and not ops:
|
||||||
|
yield {
|
||||||
|
"type": "extract_error",
|
||||||
|
"supplement_id": sid,
|
||||||
|
"filename": filename,
|
||||||
|
"error": "full_replace returned empty ops",
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
# Трансляция target_id ("r1","r2"...) → target_hash (name_hash из current_spec).
|
# Трансляция target_id ("r1","r2"...) → target_hash (name_hash из current_spec).
|
||||||
# LLM возвращает target_id, а apply_ops() читает target_hash — без этого UPDATE/DELETE уходят в UNRESOLVED.
|
# LLM возвращает target_id, а apply_ops() читает target_hash — без этого UPDATE/DELETE уходят в UNRESOLVED.
|
||||||
for _op in ops:
|
for _op in ops:
|
||||||
@@ -120,7 +129,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Arithmetic check
|
# Arithmetic check
|
||||||
check_arithmetic(ops)
|
arithmetic_warnings = check_arithmetic(ops)
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "applied",
|
"type": "applied",
|
||||||
@@ -128,6 +137,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
|||||||
"filename": filename,
|
"filename": filename,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"ops": applied_ops,
|
"ops": applied_ops,
|
||||||
|
"arithmetic_warnings": arithmetic_warnings,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -139,7 +149,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
|||||||
}
|
}
|
||||||
|
|
||||||
total_time = round(time.time() - t0, 1)
|
total_time = round(time.time() - t0, 1)
|
||||||
yield {"type": "complete", "total_time_s": total_time}
|
yield {"type": "done", "total_time_s": total_time}
|
||||||
|
|
||||||
|
|
||||||
def _elements_to_text(ej):
|
def _elements_to_text(ej):
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img src="/static/logo.svg" alt="Nubes">
|
<img src="/static/logo.svg" alt="Nubes">
|
||||||
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.20</span></span>
|
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.21</span></span>
|
||||||
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
||||||
<span id="stepUpload">○ Загрузка</span><span>→</span>
|
<span id="stepUpload">○ Загрузка</span><span>→</span>
|
||||||
<span id="stepClassify">○ Классификация</span><span>→</span>
|
<span id="stepClassify">○ Классификация</span><span>→</span>
|
||||||
@@ -224,11 +224,11 @@
|
|||||||
import { listZipFiles } from '/upload/zip/list_zip_files.js';
|
import { listZipFiles } from '/upload/zip/list_zip_files.js';
|
||||||
window.listZipFiles = listZipFiles;
|
window.listZipFiles = listZipFiles;
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/state.js?v=2.0.20"></script>
|
<script src="/static/state.js?v=2.0.21"></script>
|
||||||
<script src="/static/app_utils.js?v=2.0.20"></script>
|
<script src="/static/app_utils.js?v=2.0.21"></script>
|
||||||
<script src="/static/files.js?v=2.0.20"></script>
|
<script src="/static/files.js?v=2.0.21"></script>
|
||||||
<script src="/static/groups.js?v=2.0.20"></script>
|
<script src="/static/groups.js?v=2.0.21"></script>
|
||||||
<script src="/static/compare.js?v=2.0.20"></script>
|
<script src="/static/compare.js?v=2.0.21"></script>
|
||||||
<script src="/static/app.js?v=2.0.20"></script>
|
<script src="/static/app.js?v=2.0.21"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class TestApplyOpsAdd:
|
|||||||
def test_add(self, db):
|
def test_add(self, db):
|
||||||
ops = [{"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2025-01-01"}}]
|
ops = [{"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2025-01-01"}}]
|
||||||
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
||||||
assert s == {"added": 1, "updated": 0, "deleted": 0}
|
assert s == {"added": 1, "updated": 0, "deleted": 0, "unresolved": 0}
|
||||||
rows = spec_current.list_by_contract(CID)
|
rows = spec_current.list_by_contract(CID)
|
||||||
assert len(rows) == 1
|
assert len(rows) == 1
|
||||||
assert rows[0]["name"] == "Аренда стойко-места"
|
assert rows[0]["name"] == "Аренда стойко-места"
|
||||||
@@ -27,7 +27,7 @@ class TestApplyOpsAdd:
|
|||||||
def test_add_empty_name_unresolved(self, db):
|
def test_add_empty_name_unresolved(self, db):
|
||||||
ops = [{"action": "ADD", "new_row": {"name": ""}}]
|
ops = [{"action": "ADD", "new_row": {"name": ""}}]
|
||||||
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
||||||
assert s == {"added": 0, "updated": 0, "deleted": 0}
|
assert s == {"added": 0, "updated": 0, "deleted": 0, "unresolved": 1}
|
||||||
assert spec_current.list_by_contract(CID) == []
|
assert spec_current.list_by_contract(CID) == []
|
||||||
|
|
||||||
def test_add_multiple(self, db):
|
def test_add_multiple(self, db):
|
||||||
|
|||||||
Reference in New Issue
Block a user