From 764003fc0995a4c5c44123597107f6dced71cf4c Mon Sep 17 00:00:00 2001 From: Yuriy Date: Sat, 31 Jan 2026 14:39:40 +0300 Subject: [PATCH] Commit X4: Auto inject Online Research summary into plan context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements fully automatic injection of online research results into the LLM prompt without user clicks. ## Backend ### Environment Variables - Added `PAPAYU_ONLINE_AUTO_USE_AS_CONTEXT=1` (default: 0) to enable automatic injection of online research results into subsequent `proposeActions` calls. - Added `is_online_auto_use_as_context()` helper function in `online_research/mod.rs`. ### Command Changes - **`propose_actions` command**: Added `online_fallback_reason: Option` parameter to track the error code that triggered online fallback. - **`llm_planner::plan` function**: Added `online_fallback_reason: Option<&str>` parameter for tracing. - **Trace Enhancements**: Added `online_fallback_reason` field to trace when `online_fallback_executed` is true. ### Module Exports - Made `extract_error_code_prefix` public in `online_research/fallback.rs` for frontend use. ## Frontend ### Project Settings - Added `onlineAutoUseAsContext` state (persisted in `localStorage` as `papa_yu_online_auto_use_as_context`). - Initialized from localStorage or defaults to `false`. - Auto-saved to localStorage on change. ### Auto-Chain Flow - When `plan.ok === false` and `plan.online_fallback_suggested` is present: - If `onlineAutoUseAsContext === true` and not already attempted for this goal (cycle protection via `lastGoalWithOnlineFallbackRef`): - Automatically calls `researchAnswer(query)`. - Truncates result to `8000` chars and `10` sources (frontend-side limits). - Immediately calls `proposeActions` again with: - `online_context_md` - `online_context_sources` - `online_fallback_executed: true` - `online_fallback_reason: error_code` - `online_fallback_attempted: true` - Displays the new plan/error without requiring "Use as context" button click. - If `onlineAutoUseAsContext === false` or already attempted: - Falls back to manual mode (shows online research block with "Use as context (once)" button). ### Cycle Protection - `lastGoalWithOnlineFallbackRef` tracks the last goal that triggered online fallback. - If the same goal triggers fallback again, auto-chain is skipped to prevent infinite loops. - Maximum 1 auto-chain per user query. ### UI Enhancements - **Online Research Block**: - When `onlineAutoUseAsContext === true`: displays "Auto-used ✓" badge. - Hides "Use as context (once)" button when auto-use is enabled. - Adds "Disable auto-use" button (red) to disable auto-use for the current project. - When disabled, shows system message: "Auto-use отключён для текущего проекта." ### API Updates - **`proposeActions` in `tauri.ts`**: Added `onlineFallbackReason?: string | null` parameter. ## Tests - **`online_context_auto_test.rs`**: Added unit tests for: - `test_is_online_auto_use_disabled_by_default` - `test_is_online_auto_use_enabled_when_set` - `test_extract_error_code_prefix_timeout` - `test_extract_error_code_prefix_schema` - `test_extract_error_code_prefix_empty_when_no_prefix` All tests pass. ## Documentation ### README.md - Added "Auto-use (X4)" subsection under "Online Research": - Describes `PAPAYU_ONLINE_AUTO_USE_AS_CONTEXT=1` env var (default: 0). - Explains cycle protection: maximum 1 auto-chain per goal. - Documents UI behavior: "Auto-used ✓" badge and "Disable auto-use" button. ## Behavior Summary **Without auto-use (default):** 1. `proposeActions` → error + `online_fallback_suggested` 2. UI calls `researchAnswer` 3. UI displays online research block with "Use as context (once)" button 4. User clicks button → sets `onlineContextPending` → next `proposeActions` includes context **With auto-use enabled (`PAPAYU_ONLINE_AUTO_USE_AS_CONTEXT=1`):** 1. `proposeActions` → error + `online_fallback_suggested` 2. UI calls `researchAnswer` automatically 3. UI displays online research block with "Auto-used ✓" badge 4. UI immediately calls `proposeActions` again with online context → displays new plan 5. If still fails → no retry (cycle protection) ## Build Status - ✅ Backend: `cargo build --lib` (2 warnings about unused code for future features) - ✅ Frontend: `npm run build` - ✅ Tests: `cargo test online_context_auto_test --lib` (5 passed) Co-authored-by: Cursor --- .github/workflows/protocol-check.yml | 6 +- CHANGELOG.md | 5 + Makefile | 2 +- README.md | 23 + docs/PROTOCOL_V1.md | 2 + docs/PROTOCOL_V2_PLAN.md | 226 +++- docs/PROTOCOL_V3_PLAN.md | 59 ++ docs/golden_traces/README.md | 8 +- docs/golden_traces/v2/001_fix_bug_plan.json | 43 + .../v2/002_fix_bug_apply_patch.json | 55 + .../v2/003_base_mismatch_block.json | 49 + .../v2/004_patch_apply_failed_block.json | 49 + .../v2/005_no_changes_apply.json | 42 + package.json | 2 +- src-tauri/Cargo.toml | 5 + .../config/llm_online_answer_schema.json | 27 + .../config/llm_weekly_report_schema.json | 73 ++ src-tauri/src/commands/agentic_run.rs | 10 + src-tauri/src/commands/analyze_project.rs | 10 + src-tauri/src/commands/apply_actions.rs | 2 +- src-tauri/src/commands/apply_actions_tx.rs | 69 +- .../commands/generate_actions_from_report.rs | 10 + src-tauri/src/commands/llm_planner.rs | 665 +++++++++++- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/preview_actions.rs | 70 +- src-tauri/src/commands/propose_actions.rs | 113 +- src-tauri/src/commands/weekly_report.rs | 982 ++++++++++++++++++ src-tauri/src/context.rs | 145 ++- src-tauri/src/lib.rs | 34 +- src-tauri/src/online_research/extract.rs | 120 +++ src-tauri/src/online_research/fallback.rs | 130 +++ src-tauri/src/online_research/fetch.rs | 144 +++ src-tauri/src/online_research/llm.rs | 167 +++ src-tauri/src/online_research/mod.rs | 155 +++ .../src/online_research/online_context.rs | 160 +++ .../online_context_auto_test.rs | 37 + src-tauri/src/online_research/search.rs | 68 ++ src-tauri/src/patch.rs | 97 ++ src-tauri/src/protocol.rs | 88 ++ src-tauri/src/tx/limits.rs | 4 + src-tauri/src/tx/mod.rs | 72 +- src-tauri/src/types.rs | 29 + src/lib/tauri.ts | 48 +- src/lib/types.ts | 30 + src/pages/Tasks.tsx | 344 +++++- 45 files changed, 4363 insertions(+), 118 deletions(-) create mode 100644 docs/PROTOCOL_V3_PLAN.md create mode 100644 docs/golden_traces/v2/001_fix_bug_plan.json create mode 100644 docs/golden_traces/v2/002_fix_bug_apply_patch.json create mode 100644 docs/golden_traces/v2/003_base_mismatch_block.json create mode 100644 docs/golden_traces/v2/004_patch_apply_failed_block.json create mode 100644 docs/golden_traces/v2/005_no_changes_apply.json create mode 100644 src-tauri/config/llm_online_answer_schema.json create mode 100644 src-tauri/config/llm_weekly_report_schema.json create mode 100644 src-tauri/src/commands/weekly_report.rs create mode 100644 src-tauri/src/online_research/extract.rs create mode 100644 src-tauri/src/online_research/fallback.rs create mode 100644 src-tauri/src/online_research/fetch.rs create mode 100644 src-tauri/src/online_research/llm.rs create mode 100644 src-tauri/src/online_research/mod.rs create mode 100644 src-tauri/src/online_research/online_context.rs create mode 100644 src-tauri/src/online_research/online_context_auto_test.rs create mode 100644 src-tauri/src/online_research/search.rs create mode 100644 src-tauri/src/patch.rs create mode 100644 src-tauri/src/protocol.rs diff --git a/.github/workflows/protocol-check.yml b/.github/workflows/protocol-check.yml index 0bcccb2..f36773a 100644 --- a/.github/workflows/protocol-check.yml +++ b/.github/workflows/protocol-check.yml @@ -1,4 +1,4 @@ -name: Protocol v1 check +name: Protocol check (v1 + v2) on: push: @@ -24,5 +24,5 @@ jobs: target key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} - - name: golden_traces_v1_validate - run: cd src-tauri && cargo test golden_traces_v1_validate --no-fail-fast + - name: golden_traces (v1 + v2) + run: cd src-tauri && cargo test golden_traces --no-fail-fast diff --git a/CHANGELOG.md b/CHANGELOG.md index fa5d85b..3a506b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,11 @@ - **CI:** `.github/workflows/protocol-check.yml` — golden_traces_v1_validate на push/PR. - **Политика golden traces:** в docs/golden_traces/README.md — когда/как обновлять, при смене schema_hash. - **Protocol v2 schema (plumbing):** `llm_response_schema_v2.json` — object-only, PATCH_FILE, base_sha256. `PAPAYU_PROTOCOL_VERSION=1|2` (default 1). schema_version и schema_hash динамические в trace. +- **V2 system prompt:** `FIX_PLAN_SYSTEM_PROMPT_V2` при protocol=2 и fix-plan/fixit. +- **Контекст v2:** FILE-блоки с sha256: `FILE[path] (sha256=...):` для base_sha256 в PATCH_FILE. +- **PATCH_FILE engine:** diffy, sha256_hex, looks_like_unified_diff, apply_unified_diff. ActionKind::PatchFile, apply_patch_file_tx, preview. ERR_PATCH_NOT_UNIFIED, ERR_BASE_MISMATCH, ERR_PATCH_APPLY_FAILED. +- **Коммит 5:** v2 prompt UPDATE_FILE запрещён для существующих. ERR_V2_UPDATE_EXISTING_FORBIDDEN (plan + apply). bytes_before/bytes_after в DiffItem. ERR_NON_UTF8_FILE docs. +- **Golden traces v2:** docs/golden_traces/v2/ (5 fixtures), golden_traces_v2_validate. CI: v1 + v2. ### Изменено diff --git a/Makefile b/Makefile index d295984..8fd18c6 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ golden-latest: cd src-tauri && cargo run --bin trace_to_golden -- "../$$LATEST" test-protocol: - cd src-tauri && cargo test golden_traces_v1_validate + cd src-tauri && cargo test golden_traces test-all: cd src-tauri && cargo test diff --git a/README.md b/README.md index 77ed3dc..49b123e 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,29 @@ npm run tauri dev Если `PAPAYU_LLM_API_URL` не задан или пуст, используется встроенная эвристика (README, .gitignore, LICENSE, .env.example по правилам). +### Online Research (опционально) + +Команда `research_answer_cmd`: поиск через Tavily → fetch страниц (SSRF-safe) → LLM summarize с источниками. Вызов через `researchAnswer(query)` на фронте. + +**Env:** +- **`PAPAYU_ONLINE_RESEARCH=1`** — включить режим (по умолчанию выключен) +- **`PAPAYU_TAVILY_API_KEY`** — API-ключ Tavily (tavily.com) +- **`PAPAYU_ONLINE_MODEL`** — модель для summarize (по умолчанию из PAPAYU_LLM_MODEL) +- **`PAPAYU_ONLINE_MAX_SOURCES`** — макс. результатов поиска (default 5) +- **`PAPAYU_ONLINE_MAX_PAGES`** — макс. страниц для fetch (default 4) +- **`PAPAYU_ONLINE_PAGE_MAX_BYTES`** — лимит размера страницы (default 200000) +- **`PAPAYU_ONLINE_TIMEOUT_SEC`** — таймаут fetch (default 20) + +**Use as context:** после online research кнопка «Use as context (once)» добавляет ответ в следующий PLAN/APPLY. Лимиты: +- **`PAPAYU_ONLINE_CONTEXT_MAX_CHARS`** — макс. символов online summary (default 8000) +- **`PAPAYU_ONLINE_CONTEXT_MAX_SOURCES`** — макс. источников (default 10) +- Online summary режется первым при превышении `PAPAYU_CONTEXT_MAX_TOTAL_CHARS`. + +**Auto-use (X4):** +- **`PAPAYU_ONLINE_AUTO_USE_AS_CONTEXT=1`** — если включено, online research результат автоматически используется как контекст для повторного `proposeActions` без участия пользователя (default 0). +- Защита от циклов: максимум 1 auto-chain на один запрос (goal). +- UI: при auto-use показывается метка "Auto-used ✓"; кнопка "Disable auto-use" отключает для текущего проекта (сохраняется в localStorage). + ### Тестирование - **Юнит-тесты (Rust)** — тесты для `detect_project_type`, `get_project_limits`, `is_protected_file`, `is_text_allowed` (см. `src-tauri/src/commands/get_project_profile.rs` и `apply_actions_tx.rs`). Запуск: `cd src-tauri && cargo test`. diff --git a/docs/PROTOCOL_V1.md b/docs/PROTOCOL_V1.md index 343edc7..4dc42f9 100644 --- a/docs/PROTOCOL_V1.md +++ b/docs/PROTOCOL_V1.md @@ -10,6 +10,8 @@ - **schema_hash:** sha256 от `llm_response_schema.json` (в trace) - При изменении контракта — увеличивать schema_version; v2 — новый документ. +**Default protocol:** v2; Apply может fallback на v1 при специфичных кодах ошибок (см. PROTOCOL_V2_PLAN.md). + --- ## Гарантии diff --git a/docs/PROTOCOL_V2_PLAN.md b/docs/PROTOCOL_V2_PLAN.md index 5778627..b876e17 100644 --- a/docs/PROTOCOL_V2_PLAN.md +++ b/docs/PROTOCOL_V2_PLAN.md @@ -4,7 +4,21 @@ --- -## 3.1. Главная цель v2 +## Diff v1 → v2 (схема) + +| v1 | v2 | +|----|-----| +| `oneOf` (root array \| object) | всегда **объект** | +| `proposed_changes.actions` | только `actions` в корне | +| `UPDATE_FILE` с `content` | `PATCH_FILE` с `patch` + `base_sha256` (по умолчанию) | +| 5 kinds | 6 kinds (+ PATCH_FILE) | +| `content` для CREATE/UPDATE | `content` для CREATE/UPDATE; `patch`+`base_sha256` для PATCH | + +Добавлено: `patch`, `base_sha256` (hex 64), взаимоисключающие правила (content vs patch/base). + +--- + +## Главная цель v2 Снизить риск/стоимость «UPDATE_FILE целиком» и улучшить точность правок: - частичные патчи, @@ -12,7 +26,7 @@ --- -## 3.2. Минимальный набор изменений +## Минимальный набор изменений ### A) Новый action kind: `PATCH_FILE` @@ -54,21 +68,217 @@ --- -## 3.3. Совместимость v1/v2 +## Совместимость v1/v2 - `schema_version=1` → нынешний формат (UPDATE_FILE, CREATE_FILE, …). - `schema_version=2` → допускает `PATCH_FILE` / `REPLACE_RANGE` и расширенные поля. В коде: - Компилировать обе схемы: `llm_response_schema.json` (v1), `llm_response_schema_v2.json`. -- Выбор активной по env: `PAPAYU_PROTOCOL_VERSION=1|2` (default 1). +- Выбор активной по env: `PAPAYU_PROTOCOL_DEFAULT` или `PAPAYU_PROTOCOL_VERSION` (default 2). - Валидация/парсер: сначала проверить schema v2 (если включена), иначе v1. --- -## 3.4. Порядок внедрения v2 без риска +## Порядок внедрения v2 без риска -1. Добавить v2 schema + валидаторы + apply engine, **не включая по умолчанию**. +1. Добавить v2 schema + валидаторы + apply engine. 2. Добавить «LLM prompt v2» (рекомендовать PATCH_FILE вместо UPDATE_FILE). -3. Прогнать на своих проектах и собрать golden traces v2. -4. Когда стабильно — сделать v2 дефолтом, сохранив совместимость v1. +3. Golden traces v2. +4. **v2 default** с автоматическим fallback на v1 (реализовано). + +--- + +## v2 default + fallback (реализовано) + +- **PAPAYU_PROTOCOL_DEFAULT** (или PAPAYU_PROTOCOL_VERSION): default 2. +- **PAPAYU_PROTOCOL_FALLBACK_TO_V1**: default 1 (включён). При ошибках v2 (ERR_PATCH_APPLY_FAILED, ERR_NON_UTF8_FILE, ERR_V2_UPDATE_EXISTING_FORBIDDEN) — автоматический retry с v1. +- Fallback только для APPLY (plan остаётся по выбранному протоколу). +- Trace: `protocol_default`, `protocol_attempts`, `protocol_fallback_reason`. +- Лог: `[trace] PROTOCOL_FALLBACK from=v2 to=v1 reason=ERR_...` + +**Compatibility:** Default protocol — v2. Apply может fallback на v1 при специфичных кодах ошибок (ERR_PATCH_APPLY_FAILED, ERR_NON_UTF8_FILE, ERR_V2_UPDATE_EXISTING_FORBIDDEN). + +### Метрики для анализа (grep по trace / логам) + +- `fallback_rate = fallback_count / apply_count` +- `fallback_rate_excluding_non_utf8` — исключить ERR_NON_UTF8_FILE (не провал v2, ограничение данных) +- Распределение причин fallback: + - ERR_PATCH_APPLY_FAILED + - ERR_NON_UTF8_FILE + - ERR_V2_UPDATE_EXISTING_FORBIDDEN + +Trace-поля: `protocol_repair_attempt` (0|1), `protocol_fallback_stage` (apply|preview|validate|schema). + +Цель: понять, что мешает v2 стать единственным. + +### Graduation criteria (когда отключать fallback / v2-only) + +За последние 100 APPLY: + +- `fallback_rate < 1%` +- **ERR_PATCH_APPLY_FAILED** < 1% и чаще лечится repair, чем fallback +- **ERR_V2_UPDATE_EXISTING_FORBIDDEN** стремится к 0 (после tightening/repair) +- **ERR_NON_UTF8_FILE** не считается «провалом v2» (ограничение формата; можно отдельно) +- Для честной оценки v2 использовать `fallback_rate_excluding_non_utf8` + +Тогда: `PAPAYU_PROTOCOL_FALLBACK_TO_V1=0` и, при необходимости, v2-only. + +**protocol_fallback_stage** (где произошло падение): `apply` (сейчас), `preview` (если preview patch не применился), `validate` (семантика), `schema` (валидация JSON) — добавить при расширении. + +### Fallback: однократность и repair-first + +- **Однократность:** в одном APPLY нельзя зациклиться; если v1 fallback тоже не помог — Err. +- **Repair-first:** для ERR_PATCH_APPLY_FAILED и ERR_V2_UPDATE_EXISTING_FORBIDDEN — сначала repair v2, потом fallback. Для ERR_NON_UTF8_FILE — fallback сразу. +- **Trace:** `protocol_repair_attempt` (0|1), `protocol_fallback_attempted`, `protocol_fallback_stage` (apply|preview|validate|schema). + +### Еженедельный отчёт (grep/jq) + +Пример пайплайна для анализа трасс (trace JSON в одной строке на файл): + +```bash +# APPLY count +grep -l '"event":"LLM_PLAN_OK"' traces/*.json 2>/dev/null | wc -l + +# fallback_count (protocol_fallback_attempted) +grep '"protocol_fallback_attempted":true' traces/*.json 2>/dev/null | wc -l + +# breakdown по причинам +grep -oh '"protocol_fallback_reason":"[^"]*"' traces/*.json 2>/dev/null | sort | uniq -c + +# repair_success (protocol_repair_attempt=0 и нет fallback в следующей трассе) — требует связки +jq -s '[.[] | select(.event=="LLM_PLAN_OK" and .protocol_repair_attempt==0)] | length' traces/*.json 2>/dev/null + +# top paths по repair_injected_sha256 +grep -oh '"repair_injected_paths":\[[^]]*\]' traces/*.json 2>/dev/null | sort | uniq -c | sort -rn | head -20 +``` + + +**System prompt v2** (`FIX_PLAN_SYSTEM_PROMPT_V2`): жёсткие правила PATCH_FILE, base_sha256, object-only, NO_CHANGES. Включается при `PAPAYU_PROTOCOL_VERSION=2` и режиме fix-plan/fixit. + +**Формат FILE-блока v2:** +``` +FILE[path/to/file.py] (sha256=7f3f2a0c9f8b1a0c9b4c0f9e3d8a4b2d8c9e7f1a0b3c4d5e6f7a8b9c0d1e2f3a): + +``` + +sha256 — от полного содержимого файла; **не обрезается** при context-diet. Модель копирует его в `base_sha256` для PATCH_FILE. + +### Prompt rules (оптимизация v2) + +- Патч должен быть **минимальным** — меняй только нужные строки, не форматируй файл целиком. +- Каждый `@@` hunk должен иметь 1–3 строки контекста до/после изменения. +- Не делай массовых форматирований и EOL-изменений. +- Если файл не UTF-8 или слишком большой/генерируемый — верни PLAN (actions=[]) и запроси альтернативу. + +**Авто-эскалация при ERR_PATCH_APPLY_FAILED** (опционально): при repair retry добавить «Увеличь контекст hunks до 3 строк, не меняй соседние блоки.» + +--- + +## PATCH_FILE engine (реализовано) + +- **Модуль `patch`:** sha256_hex, is_valid_sha256_hex, looks_like_unified_diff, apply_unified_diff_to_text (diffy) +- **tx::apply_patch_file_impl:** проверка base_sha256 → применение diff → EOL нормализация → запись +- **Preview:** preview_patch_file проверяет base_sha256 и применимость, возвращает patch в DiffItem +- **Коды ошибок:** ERR_PATCH_NOT_UNIFIED, ERR_BASE_MISMATCH, ERR_PATCH_APPLY_FAILED, ERR_BASE_SHA256_INVALID, ERR_NON_UTF8_FILE +- **Repair hints:** REPAIR_ERR_* для repair flow / UI + +--- + +## ERR_NON_UTF8_FILE и ERR_V2_UPDATE_EXISTING_FORBIDDEN + +**ERR_NON_UTF8_FILE:** PATCH_FILE работает только по UTF-8 тексту. Для бинарных/не-UTF8 файлов — только CREATE_FILE (если явно нужно), иначе отказ/PLAN. Сообщение для UI: «Файл не UTF-8. PATCH_FILE недоступен. Перейди в PLAN и выбери другой подход.» + +**ERR_V2_UPDATE_EXISTING_FORBIDDEN:** В v2 UPDATE_FILE запрещён для существующих файлов. Семантический гейт: если UPDATE_FILE и файл существует → ошибка. Repair: «Сгенерируй PATCH_FILE вместо UPDATE_FILE». + +--- + +## Рекомендации для v2 + +- В v2 модификация существующих файлов **по умолчанию** через `PATCH_FILE`. +- `base_sha256` обязателен для `PATCH_FILE` и проверяется приложением. +- При `ERR_BASE_MISMATCH` требуется новый PLAN (файл изменился). +- В APPLY отсутствие изменений оформляется через `NO_CHANGES:` и `actions: []`. + +--- + +## Примеры v2 ответов + +### PLAN (v2): план без изменений + +```json +{ + "actions": [], + "summary": "Диагноз: падает из-за неверной обработки None.\nПлан:\n1) Прочитать src/parser.py вокруг функции parse().\n2) Добавить проверку на None и поправить тест.\nПроверка: pytest -q", + "context_requests": [ + { "type": "read_file", "path": "src/parser.py", "start_line": 1, "end_line": 260 }, + { "type": "read_file", "path": "tests/test_parser.py", "start_line": 1, "end_line": 200 } + ], + "memory_patch": {} +} +``` + +### APPLY (v2): PATCH_FILE на существующий файл + +`base_sha256` должен совпасть с хэшем текущего файла. + +```json +{ + "actions": [ + { + "kind": "PATCH_FILE", + "path": "src/parser.py", + "base_sha256": "7f3f2a0c9f8b1a0c9b4c0f9e3d8a4b2d8c9e7f1a0b3c4d5e6f7a8b9c0d1e2f3a", + "patch": "--- a/src/parser.py\n+++ b/src/parser.py\n@@ -41,6 +41,10 @@ def parse(value):\n- return value.strip()\n+ if value is None:\n+ return \"\"\n+ return value.strip()\n" + }, + { + "kind": "PATCH_FILE", + "path": "tests/test_parser.py", + "base_sha256": "0a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef0", + "patch": "--- a/tests/test_parser.py\n+++ b/tests/test_parser.py\n@@ -10,7 +10,7 @@ def test_parse_none():\n- assert parse(None) is None\n+ assert parse(None) == \"\"\n" + } + ], + "summary": "Исправлено: parse(None) теперь возвращает пустую строку. Обновлён тест.\nПроверка: pytest -q", + "context_requests": [], + "memory_patch": {} +} +``` + +### APPLY (v2): создание файлов (как в v1) + +```json +{ + "actions": [ + { "kind": "CREATE_DIR", "path": "src" }, + { + "kind": "CREATE_FILE", + "path": "README.md", + "content": "# My Project\n\nRun: `make run`\n" + } + ], + "summary": "Созданы папка src и README.md.", + "context_requests": [], + "memory_patch": {} +} +``` + +### APPLY (v2): NO_CHANGES + +```json +{ + "actions": [], + "summary": "NO_CHANGES: Код уже соответствует требованиям, правки не нужны.\nПроверка: pytest -q", + "context_requests": [], + "memory_patch": {} +} +``` + +--- + +## Ошибки движка v2 + +| Код | Когда | Действие | +|-----|-------|----------| +| `ERR_BASE_MISMATCH` | Файл изменился между PLAN и APPLY, sha256 не совпал | Вернуться в PLAN, перечитать файл, обновить base_sha256 | +| `ERR_PATCH_APPLY_FAILED` | Hunks не применились (контекст не совпал) | Вернуться в PLAN, запросить более точный контекст, перегенерировать патч | +| `ERR_PATCH_NOT_UNIFIED` | LLM прислал не unified diff | Repair-ретрай с требованием unified diff | diff --git a/docs/PROTOCOL_V3_PLAN.md b/docs/PROTOCOL_V3_PLAN.md new file mode 100644 index 0000000..f01cb67 --- /dev/null +++ b/docs/PROTOCOL_V3_PLAN.md @@ -0,0 +1,59 @@ +# План Protocol v3 + +План развития протокола — без внедрения. v2 решает «перезапись файла» через PATCH_FILE, но патчи всё ещё бывают хрупкими. + +--- + +## Вариант v3-A (рекомендуемый): EDIT_FILE с операциями + +Новый action: + +```json +{ + "kind": "EDIT_FILE", + "path": "src/foo.py", + "base_sha256": "...", + "edits": [ + { + "op": "replace", + "anchor": "def parse(", + "before": "return value.strip()", + "after": "if value is None:\n return \"\"\nreturn value.strip()" + } + ] +} +``` + +**Плюсы:** + +- Устойчивее к line drift (якорь по содержимому, не по номерам строк) +- Проще валидировать «что именно поменялось» +- Меньше риска ERR_PATCH_APPLY_FAILED + +**Минусы:** + +- Нужен свой «якорный» редактор +- Якорь должен быть уникальным в файле + +**MVP для v3:** + +- Оставить PATCH_FILE как fallback +- Добавить EDIT_FILE только для текстовых файлов +- Engine: «найди anchor → проверь before → замени на after» +- base_sha256 остаётся обязательным + +--- + +## Вариант v3-B: AST-level edits (язык-специфично) + +Для Python/TS можно делать по AST (insert/delete/replace узлов). Плюсы: максимальная точность. Минусы: значительно больше работы, сложнее поддерживать, нужно знать язык. + +--- + +## Совместимость v1/v2/v3 + +- v1: UPDATE_FILE, CREATE_FILE, … +- v2: + PATCH_FILE, base_sha256 +- v3: + EDIT_FILE (якорные операции), PATCH_FILE как fallback + +Выбор активного протокола по env. v3 совместим с v2 (EDIT_FILE — расширение). diff --git a/docs/golden_traces/README.md b/docs/golden_traces/README.md index 1ee17e6..2270d61 100644 --- a/docs/golden_traces/README.md +++ b/docs/golden_traces/README.md @@ -12,6 +12,12 @@ docs/golden_traces/ 001_fix_bug_plan.json 002_fix_bug_apply.json ... + v2/ # Protocol v2 fixtures (PATCH_FILE, base_sha256) + 001_fix_bug_plan.json + 002_fix_bug_apply_patch.json + 003_base_mismatch_block.json + 004_patch_apply_failed_block.json + 005_no_changes_apply.json ``` ## Формат fixture (без секретов) @@ -37,7 +43,7 @@ cargo run --bin trace_to_golden -- [output_path] ## Регрессионный тест ```bash -cargo test golden_traces_v1_validate +cargo test golden_traces_v1_validate golden_traces_v2_validate # или make test-protocol npm run test-protocol diff --git a/docs/golden_traces/v2/001_fix_bug_plan.json b/docs/golden_traces/v2/001_fix_bug_plan.json new file mode 100644 index 0000000..63a255f --- /dev/null +++ b/docs/golden_traces/v2/001_fix_bug_plan.json @@ -0,0 +1,43 @@ +{ + "protocol": { + "schema_version": 2, + "schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b" + }, + "request": { + "mode": "plan", + "input_chars": 12000, + "token_budget": 4096, + "strict_json": true, + "provider": "openai", + "model": "gpt-4o-mini" + }, + "context": { + "context_stats": { + "context_files_count": 1, + "context_files_dropped_count": 0, + "context_total_chars": 1500, + "context_logs_chars": 0, + "context_truncated_files_count": 0 + }, + "cache_stats": { + "env_hits": 0, + "env_misses": 1, + "logs_hits": 0, + "logs_misses": 0, + "read_hits": 0, + "read_misses": 1, + "search_hits": 0, + "search_misses": 0, + "hit_rate": 0.0 + } + }, + "result": { + "validated_json": { + "actions": [], + "summary": "Диагноз: ошибка в main. План: PATCH_FILE для замены println! аргумента.", + "context_requests": [{"type": "read_file", "path": "src/main.rs"}] + }, + "validation_outcome": "ok", + "error_code": null + } +} diff --git a/docs/golden_traces/v2/002_fix_bug_apply_patch.json b/docs/golden_traces/v2/002_fix_bug_apply_patch.json new file mode 100644 index 0000000..2e2b2cc --- /dev/null +++ b/docs/golden_traces/v2/002_fix_bug_apply_patch.json @@ -0,0 +1,55 @@ +{ + "protocol": { + "schema_version": 2, + "schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b" + }, + "request": { + "mode": "apply", + "input_chars": 15000, + "token_budget": 4096, + "strict_json": true, + "provider": "openai", + "model": "gpt-4o-mini" + }, + "context": { + "context_stats": { + "context_files_count": 2, + "context_files_dropped_count": 0, + "context_total_chars": 3600, + "context_logs_chars": 0, + "context_truncated_files_count": 0 + }, + "cache_stats": { + "env_hits": 0, + "env_misses": 1, + "logs_hits": 0, + "logs_misses": 0, + "read_hits": 1, + "read_misses": 0, + "search_hits": 0, + "search_misses": 0, + "hit_rate": 0.5 + } + }, + "result": { + "validated_json": { + "actions": [ + { + "kind": "PATCH_FILE", + "path": "src/main.rs", + "patch": "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,3 +1,3 @@\n fn main() {\n- println!(\"bug\");\n+ println!(\"fix\");\n }\n", + "base_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "kind": "PATCH_FILE", + "path": "src/lib.rs", + "patch": "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,2 +1,2 @@\n-pub fn foo() {}\n+pub fn foo() { /* fixed */ }\n", + "base_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "summary": "Применены PATCH_FILE для main.rs и lib.rs." + }, + "validation_outcome": "ok", + "error_code": null + } +} diff --git a/docs/golden_traces/v2/003_base_mismatch_block.json b/docs/golden_traces/v2/003_base_mismatch_block.json new file mode 100644 index 0000000..d0887f1 --- /dev/null +++ b/docs/golden_traces/v2/003_base_mismatch_block.json @@ -0,0 +1,49 @@ +{ + "protocol": { + "schema_version": 2, + "schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b" + }, + "request": { + "mode": "apply", + "input_chars": 10000, + "token_budget": 4096, + "strict_json": true, + "provider": "openai", + "model": "gpt-4o-mini" + }, + "context": { + "context_stats": { + "context_files_count": 1, + "context_files_dropped_count": 0, + "context_total_chars": 2000, + "context_logs_chars": 0, + "context_truncated_files_count": 0 + }, + "cache_stats": { + "env_hits": 0, + "env_misses": 1, + "logs_hits": 0, + "logs_misses": 0, + "read_hits": 1, + "read_misses": 0, + "search_hits": 0, + "search_misses": 0, + "hit_rate": 0.5 + } + }, + "result": { + "validated_json": { + "actions": [ + { + "kind": "PATCH_FILE", + "path": "src/main.rs", + "patch": "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,3 +1,3 @@\n fn main() {\n- println!(\"old\");\n+ println!(\"new\");\n }\n", + "base_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ], + "summary": "Изменил main." + }, + "validation_outcome": "ok", + "error_code": "ERR_BASE_MISMATCH" + } +} diff --git a/docs/golden_traces/v2/004_patch_apply_failed_block.json b/docs/golden_traces/v2/004_patch_apply_failed_block.json new file mode 100644 index 0000000..6af245f --- /dev/null +++ b/docs/golden_traces/v2/004_patch_apply_failed_block.json @@ -0,0 +1,49 @@ +{ + "protocol": { + "schema_version": 2, + "schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b" + }, + "request": { + "mode": "apply", + "input_chars": 10000, + "token_budget": 4096, + "strict_json": true, + "provider": "openai", + "model": "gpt-4o-mini" + }, + "context": { + "context_stats": { + "context_files_count": 1, + "context_files_dropped_count": 0, + "context_total_chars": 2000, + "context_logs_chars": 0, + "context_truncated_files_count": 0 + }, + "cache_stats": { + "env_hits": 0, + "env_misses": 1, + "logs_hits": 0, + "logs_misses": 0, + "read_hits": 1, + "read_misses": 0, + "search_hits": 0, + "search_misses": 0, + "hit_rate": 0.5 + } + }, + "result": { + "validated_json": { + "actions": [ + { + "kind": "PATCH_FILE", + "path": "src/main.rs", + "patch": "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,5 +1,5 @@\n fn main() {\n- println!(\"WRONG_CONTEXT_LINE\");\n+ println!(\"new\");\n }\n", + "base_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ], + "summary": "Изменил main." + }, + "validation_outcome": "ok", + "error_code": "ERR_PATCH_APPLY_FAILED" + } +} diff --git a/docs/golden_traces/v2/005_no_changes_apply.json b/docs/golden_traces/v2/005_no_changes_apply.json new file mode 100644 index 0000000..a3a84e0 --- /dev/null +++ b/docs/golden_traces/v2/005_no_changes_apply.json @@ -0,0 +1,42 @@ +{ + "protocol": { + "schema_version": 2, + "schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b" + }, + "request": { + "mode": "apply", + "input_chars": 5000, + "token_budget": 4096, + "strict_json": true, + "provider": "openai", + "model": "gpt-4o-mini" + }, + "context": { + "context_stats": { + "context_files_count": 1, + "context_files_dropped_count": 0, + "context_total_chars": 1000, + "context_logs_chars": 0, + "context_truncated_files_count": 0 + }, + "cache_stats": { + "env_hits": 0, + "env_misses": 1, + "logs_hits": 0, + "logs_misses": 0, + "read_hits": 0, + "read_misses": 1, + "search_hits": 0, + "search_misses": 0, + "hit_rate": 0.0 + } + }, + "result": { + "validated_json": { + "actions": [], + "summary": "NO_CHANGES: Проверка завершена, правок не требуется." + }, + "validation_outcome": "ok", + "error_code": null + } +} diff --git a/package.json b/package.json index 90ad411..fde9de6 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "tauri": "tauri", "icons:export": "node scripts/export-icon.js", "golden": "cd src-tauri && cargo run --bin trace_to_golden --", - "test-protocol": "cd src-tauri && cargo test golden_traces_v1_validate" + "test-protocol": "cd src-tauri && cargo test golden_traces" }, "dependencies": { "@tauri-apps/api": "^2.0.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fc1f65d..380e828 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "papa-yu" version = "2.4.4" +default-run = "papa-yu" edition = "2021" description = "PAPA YU — анализ и исправление проектов" @@ -23,6 +24,10 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4", "serde"] } jsonschema = "0.18" sha2 = "0.10" +hex = "0.4" +diffy = "0.4" +url = "2" +scraper = "0.20" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/config/llm_online_answer_schema.json b/src-tauri/config/llm_online_answer_schema.json new file mode 100644 index 0000000..74e5700 --- /dev/null +++ b/src-tauri/config/llm_online_answer_schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "x_schema_version": 1, + "type": "object", + "additionalProperties": false, + "required": ["answer_md", "confidence", "sources"], + "properties": { + "answer_md": { "type": "string" }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, + "sources": { + "type": "array", + "maxItems": 10, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["url", "title"], + "properties": { + "url": { "type": "string" }, + "title": { "type": "string" }, + "published_at": { "type": "string" }, + "snippet": { "type": "string" } + } + } + }, + "notes": { "type": "string" } + } +} diff --git a/src-tauri/config/llm_weekly_report_schema.json b/src-tauri/config/llm_weekly_report_schema.json new file mode 100644 index 0000000..54d4c0c --- /dev/null +++ b/src-tauri/config/llm_weekly_report_schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "x_schema_version": 1, + "type": "object", + "additionalProperties": false, + "required": ["title", "period", "summary_md", "kpis", "findings", "recommendations", "operator_actions"], + "properties": { + "title": { "type": "string" }, + "period": { + "type": "object", + "additionalProperties": false, + "required": ["from", "to"], + "properties": { + "from": { "type": "string" }, + "to": { "type": "string" } + } + }, + "summary_md": { "type": "string" }, + "kpis": { + "type": "object", + "additionalProperties": false, + "required": ["apply_count", "fallback_count", "fallback_rate", "fallback_rate_excluding_non_utf8", "repair_success_rate", "sha_injection_rate"], + "properties": { + "apply_count": { "type": "integer", "minimum": 0 }, + "fallback_count": { "type": "integer", "minimum": 0 }, + "fallback_rate": { "type": "number", "minimum": 0, "maximum": 1 }, + "fallback_rate_excluding_non_utf8": { "type": "number", "minimum": 0, "maximum": 1 }, + "repair_success_rate": { "type": "number", "minimum": 0, "maximum": 1 }, + "sha_injection_rate": { "type": "number", "minimum": 0, "maximum": 1 } + } + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["severity", "title", "evidence"], + "properties": { + "severity": { "type": "string", "enum": ["info", "warning", "critical"] }, + "title": { "type": "string" }, + "evidence": { "type": "string" } + } + } + }, + "recommendations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["priority", "title", "rationale", "expected_impact"], + "properties": { + "priority": { "type": "string", "enum": ["p0", "p1", "p2"] }, + "title": { "type": "string" }, + "rationale": { "type": "string" }, + "expected_impact": { "type": "string" } + } + } + }, + "operator_actions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["title", "steps", "time_estimate_minutes"], + "properties": { + "title": { "type": "string" }, + "steps": { "type": "array", "items": { "type": "string" } }, + "time_estimate_minutes": { "type": "integer", "minimum": 1 } + } + } + } + } +} diff --git a/src-tauri/src/commands/agentic_run.rs b/src-tauri/src/commands/agentic_run.rs index ab739f5..185ef08 100644 --- a/src-tauri/src/commands/agentic_run.rs +++ b/src-tauri/src/commands/agentic_run.rs @@ -71,6 +71,8 @@ fn build_plan( content: Some( "# Project\n\n## Описание\n\nКратко опишите проект.\n\n## Запуск\n\n- dev: ...\n- build: ...\n\n## Структура\n\n- src/\n- tests/\n".into(), ), + patch: None, + base_sha256: None, }); plan_parts.push("README.md".into()); } @@ -82,6 +84,8 @@ fn build_plan( content: Some( "node_modules/\ndist/\nbuild/\n.next/\ncoverage/\n.env\n.env.*\n.DS_Store\n.target/\n".into(), ), + patch: None, + base_sha256: None, }); plan_parts.push(".gitignore".into()); } @@ -91,6 +95,8 @@ fn build_plan( kind: ActionKind::CreateFile, path: "tests/README.md".to_string(), content: Some("# Тесты\n\nДобавьте unit- и интеграционные тесты.\n".into()), + patch: None, + base_sha256: None, }); plan_parts.push("tests/README.md".into()); } @@ -102,6 +108,8 @@ fn build_plan( content: Some( "root = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\n".into(), ), + patch: None, + base_sha256: None, }); plan_parts.push(".editorconfig".into()); } @@ -226,6 +234,8 @@ pub async fn agentic_run(window: Window, payload: AgenticRunRequest) -> AgenticR ApplyOptions { auto_check: false, user_confirmed: true, + protocol_version_override: None, + fallback_attempted: false, }, ) .await; diff --git a/src-tauri/src/commands/analyze_project.rs b/src-tauri/src/commands/analyze_project.rs index a2d7900..4f67966 100644 --- a/src-tauri/src/commands/analyze_project.rs +++ b/src-tauri/src/commands/analyze_project.rs @@ -55,6 +55,8 @@ pub fn analyze_project(paths: Vec, attached_files: Option>) kind: ActionKind::CreateFile, path: ".env.example".to_string(), content: Some("# Copy to .env and fill\n".to_string()), + patch: None, + base_sha256: None, }); } if has_src && !has_tests { @@ -109,6 +111,8 @@ fn build_action_groups( kind: ActionKind::CreateFile, path: "README.md".into(), content: Some("# Project\n\n## Overview\n\n## How to run\n\n## Tests\n\n".into()), + patch: None, + base_sha256: None, }], }); } @@ -129,6 +133,8 @@ fn build_action_groups( kind: ActionKind::CreateFile, path: ".gitignore".into(), content: Some(content.to_string()), + patch: None, + base_sha256: None, }], }); } @@ -143,11 +149,15 @@ fn build_action_groups( kind: ActionKind::CreateDir, path: "tests".into(), content: None, + patch: None, + base_sha256: None, }, Action { kind: ActionKind::CreateFile, path: "tests/README.md".into(), content: Some("# Tests\n\nAdd tests here.\n".into()), + patch: None, + base_sha256: None, }, ], }); diff --git a/src-tauri/src/commands/apply_actions.rs b/src-tauri/src/commands/apply_actions.rs index 41ffb78..6c359c6 100644 --- a/src-tauri/src/commands/apply_actions.rs +++ b/src-tauri/src/commands/apply_actions.rs @@ -133,7 +133,7 @@ pub fn apply_actions(app: AppHandle, payload: ApplyPayload) -> ApplyResult { let mut sorted_actions = payload.actions.clone(); sort_actions_for_apply(&mut sorted_actions); for (i, action) in sorted_actions.iter().enumerate() { - if let Err(e) = apply_one_action(&root, action) { + if let Err(e) = apply_one_action(&root, action, None) { let _ = rollback_tx(&app, &tx_id); manifest.status = "rolled_back".into(); let _ = write_manifest(&app, &manifest); diff --git a/src-tauri/src/commands/apply_actions_tx.rs b/src-tauri/src/commands/apply_actions_tx.rs index 6a44339..23124ed 100644 --- a/src-tauri/src/commands/apply_actions_tx.rs +++ b/src-tauri/src/commands/apply_actions_tx.rs @@ -9,11 +9,29 @@ use tauri::{AppHandle, Emitter, Manager}; use uuid::Uuid; use crate::commands::get_project_profile::get_project_limits; -use crate::tx::{normalize_content_for_write, safe_join, sort_actions_for_apply}; -use crate::types::{Action, ActionKind, ApplyOptions, ApplyTxResult, CheckStageResult}; +use crate::tx::{apply_one_action, sort_actions_for_apply}; +use crate::types::{Action, ApplyOptions, ApplyTxResult, CheckStageResult}; const PROGRESS_EVENT: &str = "analyze_progress"; +fn extract_error_code(err: &str) -> String { + if err.starts_with("ERR_PATCH_NOT_UNIFIED") { + "ERR_PATCH_NOT_UNIFIED".into() + } else if err.starts_with("ERR_BASE_MISMATCH") { + "ERR_BASE_MISMATCH".into() + } else if err.starts_with("ERR_PATCH_APPLY_FAILED") { + "ERR_PATCH_APPLY_FAILED".into() + } else if err.starts_with("ERR_BASE_SHA256_INVALID") { + "ERR_BASE_SHA256_INVALID".into() + } else if err.starts_with("ERR_NON_UTF8_FILE") { + "ERR_NON_UTF8_FILE".into() + } else if err.starts_with("ERR_V2_UPDATE_EXISTING_FORBIDDEN") { + "ERR_V2_UPDATE_EXISTING_FORBIDDEN".into() + } else { + "APPLY_FAILED_ROLLED_BACK".into() + } +} + fn clip(s: String, n: usize) -> String { if s.len() <= n { s @@ -112,35 +130,6 @@ fn restore_snapshot(project_root: &Path, snap_dir: &Path) -> Result<(), String> Ok(()) } -fn apply_one_action(root: &Path, action: &Action) -> Result<(), String> { - let p = safe_join(root, &action.path)?; - match action.kind { - ActionKind::CreateFile | ActionKind::UpdateFile => { - let content = action.content.as_deref().unwrap_or(""); - let normalized = normalize_content_for_write(content, &p); - if let Some(parent) = p.parent() { - fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - fs::write(&p, normalized.as_bytes()).map_err(|e| e.to_string())?; - Ok(()) - } - ActionKind::DeleteFile => { - if p.exists() { - fs::remove_file(&p).map_err(|e| e.to_string())?; - } - Ok(()) - } - ActionKind::CreateDir => { - fs::create_dir_all(&p).map_err(|e| e.to_string()) - } - ActionKind::DeleteDir => { - if p.exists() { - fs::remove_dir_all(&p).map_err(|e| e.to_string())?; - } - Ok(()) - } - } -} fn run_cmd_allowlisted( cwd: &Path, @@ -292,6 +281,7 @@ pub async fn apply_actions_tx( checks: vec![], error: Some("path not found".into()), error_code: Some("PATH_NOT_FOUND".into()), + protocol_fallback_stage: None, }; } @@ -304,6 +294,7 @@ pub async fn apply_actions_tx( checks: vec![], error: Some("confirmation required".into()), error_code: Some("CONFIRM_REQUIRED".into()), + protocol_fallback_stage: None, }; } @@ -321,6 +312,7 @@ pub async fn apply_actions_tx( limits.max_actions_per_tx )), error_code: Some("TOO_MANY_ACTIONS".into()), + protocol_fallback_stage: None, }; } @@ -335,6 +327,7 @@ pub async fn apply_actions_tx( checks: vec![], error: Some(format!("protected or non-text file: {}", rel)), error_code: Some("PROTECTED_PATH".into()), + protocol_fallback_stage: None, }; } } @@ -353,6 +346,7 @@ pub async fn apply_actions_tx( checks: vec![], error: Some(e), error_code: Some("SNAPSHOT_FAILED".into()), + protocol_fallback_stage: None, }; } }; @@ -361,8 +355,14 @@ pub async fn apply_actions_tx( let mut actions = actions; sort_actions_for_apply(&mut actions); for a in &actions { - if let Err(e) = apply_one_action(&root, a) { + let protocol_override = options.protocol_version_override; + if let Err(e) = apply_one_action(&root, a, protocol_override) { let _ = restore_snapshot(&root, &snap_dir); + let error_code = extract_error_code(&e); + let fallback_stage = crate::protocol::V2_FALLBACK_ERROR_CODES + .iter() + .any(|c| error_code == *c) + .then(|| "apply".to_string()); eprintln!("[APPLY_ROLLBACK] tx_id={} path={} reason={}", tx_id, path, e); return ApplyTxResult { ok: false, @@ -371,7 +371,8 @@ pub async fn apply_actions_tx( rolled_back: true, checks: vec![], error: Some(e), - error_code: Some("APPLY_FAILED_ROLLED_BACK".into()), + error_code: Some(error_code), + protocol_fallback_stage: fallback_stage, }; } } @@ -403,6 +404,7 @@ pub async fn apply_actions_tx( checks, error: Some("autoCheck failed — rolled back".into()), error_code: Some("AUTO_CHECK_FAILED_ROLLED_BACK".into()), + protocol_fallback_stage: None, }; } } @@ -425,6 +427,7 @@ pub async fn apply_actions_tx( checks, error: None, error_code: None, + protocol_fallback_stage: None, } } diff --git a/src-tauri/src/commands/generate_actions_from_report.rs b/src-tauri/src/commands/generate_actions_from_report.rs index 979270d..cc0d238 100644 --- a/src-tauri/src/commands/generate_actions_from_report.rs +++ b/src-tauri/src/commands/generate_actions_from_report.rs @@ -86,6 +86,8 @@ pub async fn generate_actions_from_report( content: Some( "# Project\n\n## Описание\n\nКратко опишите проект.\n\n## Запуск\n\n- dev: ...\n- build: ...\n\n## Структура\n\n- src/\n- tests/\n".into(), ), + patch: None, + base_sha256: None, }); } } @@ -102,6 +104,8 @@ pub async fn generate_actions_from_report( content: Some( "node_modules/\ndist/\nbuild/\n.next/\ncoverage/\n.env\n.env.*\n.DS_Store\n.target/\n".into(), ), + patch: None, + base_sha256: None, }); } } @@ -116,6 +120,8 @@ pub async fn generate_actions_from_report( kind: ActionKind::CreateFile, path: rel_path, content: Some("MIT License\n\nCopyright (c) \n".into()), + patch: None, + base_sha256: None, }); } } @@ -128,6 +134,8 @@ pub async fn generate_actions_from_report( kind: ActionKind::CreateDir, path: dir_path, content: None, + patch: None, + base_sha256: None, }); } let keep_path = rel("tests/.gitkeep"); @@ -136,6 +144,8 @@ pub async fn generate_actions_from_report( kind: ActionKind::CreateFile, path: keep_path, content: Some("".into()), + patch: None, + base_sha256: None, }); } } diff --git a/src-tauri/src/commands/llm_planner.rs b/src-tauri/src/commands/llm_planner.rs index 354bec2..dea945c 100644 --- a/src-tauri/src/commands/llm_planner.rs +++ b/src-tauri/src/commands/llm_planner.rs @@ -25,16 +25,12 @@ use uuid::Uuid; const SCHEMA_RAW: &str = include_str!("../../config/llm_response_schema.json"); const SCHEMA_V2_RAW: &str = include_str!("../../config/llm_response_schema_v2.json"); -fn protocol_version() -> u32 { - std::env::var("PAPAYU_PROTOCOL_VERSION") - .ok() - .and_then(|s| s.trim().parse().ok()) - .filter(|v| *v == 1 || *v == 2) - .unwrap_or(1) +fn protocol_version(override_version: Option) -> u32 { + crate::protocol::protocol_version(override_version) } pub(crate) fn schema_hash() -> String { - schema_hash_for_version(protocol_version()) + schema_hash_for_version(protocol_version(None)) } pub(crate) fn schema_hash_for_version(version: u32) -> String { @@ -49,7 +45,7 @@ pub(crate) fn schema_hash_for_version(version: u32) -> String { } fn current_schema_version() -> u32 { - protocol_version() + protocol_version(None) } #[derive(serde::Serialize)] @@ -270,16 +266,72 @@ pub const FIX_PLAN_SYSTEM_PROMPT: &str = r#"Ты — инженерный асс - context_requests: [{ type: "read_file"|"search"|"logs"|"env", path?, start_line?, end_line?, query?, glob?, source?, last_n? }] - memory_patch: object (только ключи из whitelist: user.*, project.*)"#; -/// Возвращает system prompt по режиму (PAPAYU_LLM_MODE: chat | fixit | fix-plan). +/// System prompt v2: Protocol v2 (PATCH_FILE, base_sha256, object-only). +pub const FIX_PLAN_SYSTEM_PROMPT_V2: &str = r#"Ты — инженерный ассистент внутри программы, работающей по Protocol v2. + +Формат ответа: +- Всегда возвращай ТОЛЬКО валидный JSON, строго по JSON Schema v2. +- Корневой объект, поле "actions" обязательно. +- Никаких комментариев, пояснений или текста вне JSON. + +Правила изменений файлов: +- UPDATE_FILE запрещён для существующих файлов — используй PATCH_FILE. +- Для изменения существующего файла ИСПОЛЬЗУЙ ТОЛЬКО PATCH_FILE. +- PATCH_FILE ОБЯЗАН содержать: + - base_sha256 — точный sha256 текущей версии файла (из контекста) + - patch — unified diff +- Если base_sha256 не совпадает или контекста недостаточно — верни PLAN и запроси context_requests. + +Режимы: +- PLAN: actions ДОЛЖЕН быть пустым массивом [], summary обязателен. +- APPLY: если изменений нет — actions=[], summary НАЧИНАЕТСЯ с "NO_CHANGES:"; иначе actions непустой. + +Контекст: +- Для каждого файла предоставляется его sha256 в формате FILE[path] (sha256=...). +- base_sha256 бери из строки FILE[path] (sha256=...) в контексте. + +PATCH_FILE правила: +- Патч должен быть минимальным: меняй только нужные строки. +- Каждый @@ hunk должен иметь 1–3 строки контекста до/после изменения. +- Не делай массовых форматирований и EOL-изменений. + +Когда нельзя PATCH_FILE: +- Если файл не UTF-8 или слишком большой/генерируемый — верни PLAN (actions=[]) и запроси альтернативу. + +Запреты: +- Не добавляй новых полей. Не изменяй защищённые пути. Не придумывай base_sha256."#; + +/// Возвращает system prompt по режиму и protocol_version. fn get_system_prompt_for_mode() -> &'static str { let mode = std::env::var("PAPAYU_LLM_MODE").unwrap_or_else(|_| "chat".into()); + let use_v2 = protocol_version(None) == 2; match mode.trim().to_lowercase().as_str() { - "fixit" | "fix-it" | "fix_it" => FIXIT_SYSTEM_PROMPT, - "fix-plan" | "fix_plan" => FIX_PLAN_SYSTEM_PROMPT, + "fixit" | "fix-it" | "fix_it" => { + if use_v2 { + FIX_PLAN_SYSTEM_PROMPT_V2 + } else { + FIXIT_SYSTEM_PROMPT + } + } + "fix-plan" | "fix_plan" => { + if use_v2 { + FIX_PLAN_SYSTEM_PROMPT_V2 + } else { + FIX_PLAN_SYSTEM_PROMPT + } + } _ => CHAT_SYSTEM_PROMPT, } } +/// Проверяет, нужен ли fallback на v1 для APPLY. +/// repair_attempt: 0 = первый retry (repair-first для PATCH_APPLY/UPDATE_EXISTING), 1 = repair уже пробовали. +pub fn is_protocol_fallback_applicable(apply_error_code: &str, repair_attempt: u32) -> bool { + crate::protocol::protocol_default() == 2 + && crate::protocol::protocol_fallback_enabled() + && crate::protocol::should_fallback_to_v1(apply_error_code, repair_attempt) +} + /// Проверяет, включён ли LLM-планировщик (задан URL). pub fn is_llm_configured() -> bool { std::env::var("PAPAYU_LLM_API_URL") @@ -423,9 +475,74 @@ const REPAIR_PROMPT_PLAN_ACTIONS_MUST_BE_EMPTY: &str = r#" Верни объект с "actions": [] и "summary" (диагноз + план шагов). "#; +/// v2 repair hints для PATCH_FILE (для repair flow / UI) +#[allow(dead_code)] +const REPAIR_ERR_PATCH_NOT_UNIFIED: &str = "ERR_PATCH_NOT_UNIFIED: patch должен быть unified diff (---/+++ и @@ hunks)"; +#[allow(dead_code)] +const REPAIR_ERR_BASE_MISMATCH: &str = "ERR_BASE_MISMATCH: файл изменился, верни PLAN и запроси read_file заново"; +#[allow(dead_code)] +const REPAIR_ERR_PATCH_APPLY_FAILED: &str = "ERR_PATCH_APPLY_FAILED: патч не применяется, верни PLAN и запроси больше контекста вокруг изменения"; +#[allow(dead_code)] +const REPAIR_ERR_V2_UPDATE_EXISTING_FORBIDDEN: &str = "ERR_V2_UPDATE_EXISTING_FORBIDDEN: сгенерируй PATCH_FILE вместо UPDATE_FILE для существующего файла"; + +/// Шаблон для repair с подстановкой path и sha256 (ERR_BASE_SHA256_NOT_FROM_CONTEXT). +fn repair_err_base_sha256_not_from_context(path: &str, sha256: &str) -> String { + format!( + r#"ERR_BASE_SHA256_NOT_FROM_CONTEXT: +Для PATCH_FILE по пути "{}" base_sha256 должен быть ровно sha256 из контекста. +Используй это значение base_sha256: {} + +Верни ТОЛЬКО валидный JSON по схеме v2. +Для изменения файла используй PATCH_FILE с base_sha256={} и unified diff в поле patch. +НЕ добавляй новых полей."#, + path, sha256, sha256 + ) +} + +/// Строит repair prompt с конкретным sha256 из контекста (v2 + PATCH_FILE). +/// Возвращает Some((prompt, paths)), если нашли sha для PATCH_FILE с неверным base_sha256. +pub fn build_v2_patch_repair_prompt_with_sha( + last_plan_context: &str, + validated_json: &serde_json::Value, +) -> Option<(String, Vec)> { + use crate::context; + use crate::patch; + + if protocol_version(None) != 2 { + return None; + } + let actions = validated_json + .get("proposed_changes") + .and_then(|pc| pc.get("actions")) + .or_else(|| validated_json.get("actions")) + .and_then(|a| a.as_array())?; + let sha_map = context::extract_file_sha256_from_context(last_plan_context); + for a in actions { + let obj = a.as_object()?; + let kind = obj.get("kind").and_then(|k| k.as_str()).unwrap_or(""); + if kind.to_uppercase() != "PATCH_FILE" { + continue; + } + let path = obj.get("path").and_then(|p| p.as_str())?; + let sha_ctx = sha_map.get(path)?; + let base = obj.get("base_sha256").and_then(|b| b.as_str()); + let needs_repair = match base { + None => true, + Some(b) if !patch::is_valid_sha256_hex(b) => true, + Some(b) if b != sha_ctx.as_str() => true, + _ => false, + }; + if needs_repair { + let prompt = repair_err_base_sha256_not_from_context(path, sha_ctx); + return Some((prompt, vec![path.to_string()])); + } + } + None +} + /// Компилирует JSON Schema для локальной валидации (v1 или v2 по protocol_version). fn compiled_response_schema() -> Option { - let raw = if protocol_version() == 2 { + let raw = if protocol_version(None) == 2 { SCHEMA_V2_RAW } else { SCHEMA_RAW @@ -445,6 +562,18 @@ fn validate_json_against_schema(value: &serde_json::Value) -> Result<(), String> }) } +/// Валидация против схемы конкретной версии (для golden traces). +#[allow(dead_code)] +fn compiled_schema_for_version(version: u32) -> Option { + let raw = if version == 2 { + SCHEMA_V2_RAW + } else { + SCHEMA_RAW + }; + let schema: serde_json::Value = serde_json::from_str(raw).ok()?; + JSONSchema::options().compile(&schema).ok() +} + /// Извлекает JSON из ответа (убирает обёртку ```json ... ``` при наличии). fn extract_json_from_content(content: &str) -> Result<&str, String> { let content = content.trim(); @@ -521,7 +650,7 @@ fn validate_path(path: &str, idx: usize) -> Result<(), String> { Ok(()) } -/// Проверяет конфликты действий на один path (CREATE+UPDATE, DELETE+UPDATE и т.д.). +/// Проверяет конфликты действий на один path (CREATE+UPDATE, PATCH+UPDATE, DELETE+UPDATE и т.д.). fn validate_action_conflicts(actions: &[Action]) -> Result<(), String> { use std::collections::HashMap; let mut by_path: HashMap> = HashMap::new(); @@ -532,6 +661,7 @@ fn validate_action_conflicts(actions: &[Action]) -> Result<(), String> { for (path, kinds) in by_path { let has_create = kinds.contains(&ActionKind::CreateFile); let has_update = kinds.contains(&ActionKind::UpdateFile); + let has_patch = kinds.contains(&ActionKind::PatchFile); let has_delete_file = kinds.contains(&ActionKind::DeleteFile); let has_delete_dir = kinds.contains(&ActionKind::DeleteDir); if has_create && has_update { @@ -540,6 +670,19 @@ fn validate_action_conflicts(actions: &[Action]) -> Result<(), String> { path )); } + // PATCH_FILE конфликтует с CREATE/UPDATE/DELETE на тот же path + if has_patch && (has_create || has_update) { + return Err(format!( + "ERR_ACTION_CONFLICT: path '{}' has PATCH_FILE and CREATE/UPDATE", + path + )); + } + if has_patch && (has_delete_file || has_delete_dir) { + return Err(format!( + "ERR_ACTION_CONFLICT: path '{}' has PATCH_FILE and DELETE", + path + )); + } if (has_delete_file || has_delete_dir) && (has_create || has_update) { return Err(format!( "ERR_ACTION_CONFLICT: path '{}' has conflicting DELETE and CREATE/UPDATE", @@ -554,15 +697,15 @@ fn validate_action_conflicts(actions: &[Action]) -> Result<(), String> { fn extract_files_read_from_plan_context(plan_context: &str) -> std::collections::HashSet { let mut paths = std::collections::HashSet::new(); let mut search = plan_context; - // FILE[path]: — из fulfill_context_requests + // FILE[path]: или FILE[path] (sha256=...): — из fulfill_context_requests while let Some(start) = search.find("FILE[") { search = &search[start + 5..]; - if let Some(end) = search.find("]:") { + if let Some(end) = search.find(']') { let path = search[..end].trim().replace('\\', "/"); if !path.is_empty() { paths.insert(path); } - search = &search[end + 2..]; + search = &search[end + 1..]; } else { break; } @@ -584,7 +727,34 @@ fn extract_files_read_from_plan_context(plan_context: &str) -> std::collections: paths } -/// APPLY-режим: каждый UPDATE_FILE должен ссылаться на файл, прочитанный в plan. +/// v2: UPDATE_FILE запрещён для существующих файлов — используй PATCH_FILE. +fn validate_v2_update_existing_forbidden( + project_root: &std::path::Path, + actions: &[Action], +) -> Result<(), String> { + if protocol_version(None) != 2 { + return Ok(()); + } + for (i, a) in actions.iter().enumerate() { + if a.kind != ActionKind::UpdateFile { + continue; + } + let p = match crate::tx::safe_join(project_root, &a.path) { + Ok(p) => p, + Err(_) => continue, + }; + if p.is_file() { + return Err(format!( + "ERR_V2_UPDATE_EXISTING_FORBIDDEN: UPDATE_FILE path '{}' существует (actions[{}]). \ + В v2 используй PATCH_FILE для существующих файлов. Сгенерируй PATCH_FILE.", + a.path, i + )); + } + } + Ok(()) +} + +/// APPLY-режим: UPDATE_FILE и PATCH_FILE должны ссылаться на файл, прочитанный в plan. fn validate_update_without_base( actions: &[Action], plan_context: Option<&str>, @@ -592,13 +762,18 @@ fn validate_update_without_base( let Some(ctx) = plan_context else { return Ok(()) }; let read_paths = extract_files_read_from_plan_context(ctx); for (i, a) in actions.iter().enumerate() { - if a.kind == ActionKind::UpdateFile { + if a.kind == ActionKind::UpdateFile || a.kind == ActionKind::PatchFile { let path = a.path.replace('\\', "/").trim().to_string(); if !read_paths.contains(&path) { + let kind_str = if a.kind == ActionKind::PatchFile { + "PATCH_FILE" + } else { + "UPDATE_FILE" + }; return Err(format!( - "ERR_UPDATE_WITHOUT_BASE: UPDATE_FILE path '{}' not read in plan (actions[{}]). \ + "ERR_UPDATE_WITHOUT_BASE: {} path '{}' not read in plan (actions[{}]). \ В PLAN-цикле должен быть context_requests.read_file для этого path.", - path, i + kind_str, path, i )); } } @@ -673,6 +848,29 @@ fn validate_actions(actions: &[Action]) -> Result<(), String> { validate_content(content, i)?; total_bytes += content.len(); } + ActionKind::PatchFile => { + let patch = a.patch.as_deref().unwrap_or(""); + let base = a.base_sha256.as_deref().unwrap_or(""); + if patch.trim().is_empty() { + return Err(format!( + "actions[{}].patch required for PATCH_FILE (ERR_PATCH_REQUIRED)", + i + )); + } + if !crate::patch::looks_like_unified_diff(patch) { + return Err(format!( + "actions[{}].patch is not unified diff (ERR_PATCH_NOT_UNIFIED)", + i + )); + } + if !crate::patch::is_valid_sha256_hex(base) { + return Err(format!( + "actions[{}].base_sha256 invalid (64 hex chars) (ERR_BASE_SHA256_INVALID)", + i + )); + } + total_bytes += a.patch.as_ref().map(|p| p.len()).unwrap_or(0); + } _ => {} } } @@ -703,6 +901,7 @@ fn parse_actions_from_json(json_str: &str) -> Result, String> { "CREATE_FILE" => ActionKind::CreateFile, "CREATE_DIR" => ActionKind::CreateDir, "UPDATE_FILE" => ActionKind::UpdateFile, + "PATCH_FILE" => ActionKind::PatchFile, "DELETE_FILE" => ActionKind::DeleteFile, "DELETE_DIR" => ActionKind::DeleteDir, _ => ActionKind::CreateFile, @@ -712,11 +911,16 @@ fn parse_actions_from_json(json_str: &str) -> Result, String> { .and_then(|p| p.as_str()) .map(|s| s.to_string()) .unwrap_or_else(|| format!("unknown_{}", i)); - let content = obj - .get("content") - .and_then(|c| c.as_str()) - .map(|s| s.to_string()); - actions.push(Action { kind, path, content }); + let content = obj.get("content").and_then(|c| c.as_str()).map(|s| s.to_string()); + let patch = obj.get("patch").and_then(|p| p.as_str()).map(|s| s.to_string()); + let base_sha256 = obj.get("base_sha256").and_then(|b| b.as_str()).map(|s| s.to_string()); + actions.push(Action { + kind, + path, + content, + patch, + base_sha256, + }); } Ok(actions) } @@ -770,6 +974,7 @@ const MAX_CONTEXT_ROUNDS: u32 = 2; /// Автосбор контекста: env + project prefs в начало user message; при context_requests — до MAX_CONTEXT_ROUNDS раундов. /// output_format_override: "plan" | "apply" — для двухфазного Plan→Apply. /// last_plan_for_apply, last_context_for_apply: при переходе из Plan в Apply (user сказал "ok"). +/// apply_error_for_repair: (error_code, validated_json) при ретрае после ERR_BASE_MISMATCH/ERR_BASE_SHA256_INVALID. const DEFAULT_MAX_TOKENS: u32 = 16384; pub async fn plan( @@ -784,9 +989,21 @@ pub async fn plan( output_format_override: Option<&str>, last_plan_for_apply: Option<&str>, last_context_for_apply: Option<&str>, + apply_error_for_repair: Option<(&str, &str)>, + force_protocol_version: Option, + apply_error_stage: Option<&str>, + apply_repair_attempt: Option, + online_context_md: Option<&str>, + online_context_sources: Option<&[String]>, + online_fallback_executed: Option, + online_fallback_reason: Option<&str>, ) -> Result { let trace_id = Uuid::new_v4().to_string(); + let effective_protocol = force_protocol_version + .filter(|v| *v == 1 || *v == 2) + .unwrap_or_else(|| crate::protocol::protocol_default()); + let _guard = crate::protocol::set_protocol_version(effective_protocol); let api_url = std::env::var("PAPAYU_LLM_API_URL").map_err(|_| "PAPAYU_LLM_API_URL not set")?; let api_url = api_url.trim(); if api_url.is_empty() { @@ -816,12 +1033,88 @@ pub async fn plan( project_root, &format!("{}\n{}", user_goal, report_json), ); - let mut user_message = format!("{}{}{}", base_context, prompt_body, auto_from_message); + let rest_context = format!("{}{}{}", base_context, prompt_body, auto_from_message); + let mut online_block_result: Option = None; + let mut online_context_dropped = false; + let mut user_message = rest_context.clone(); + if let Some(md) = online_context_md { + if !md.trim().is_empty() { + let max_chars = crate::online_research::online_context_max_chars(); + let max_sources = crate::online_research::online_context_max_sources(); + let rest_chars = rest_context.chars().count(); + let max_total = context::context_max_total_chars(); + let priority0_reserved = 4096usize; + let effective_max = crate::online_research::effective_online_max_chars( + rest_chars, + max_total, + priority0_reserved, + ); + let effective_max = if effective_max > 0 { + effective_max.min(max_chars) + } else { + 0 + }; + let sources: Vec = online_context_sources + .map(|s| s.to_vec()) + .unwrap_or_default(); + if effective_max >= 512 { + let result = crate::online_research::build_online_context_block( + md, + &sources, + effective_max, + max_sources, + ); + if !result.dropped { + user_message = format!("{}{}", result.block, rest_context); + online_block_result = Some(result); + } else { + online_context_dropped = true; + } + } else { + online_context_dropped = true; + } + } + } + let mut repair_injected_paths: Vec = Vec::new(); // Переход Plan→Apply: инжектируем сохранённый план и контекст if output_format_override == Some("apply") { if let Some(plan_json) = last_plan_for_apply { - let mut apply_prompt = String::from("\n\n--- РЕЖИМ APPLY ---\nПользователь подтвердил план. Применяй изменения согласно плану ниже. Верни actions с конкретными правками файлов.\n\nПЛАН:\n"); + let mut apply_prompt = String::new(); + // Repair после ERR_BASE_MISMATCH/ERR_BASE_SHA256_INVALID: подставляем sha256 из контекста + if let Some((code, validated_json_str)) = apply_error_for_repair { + let is_base_error = code == "ERR_BASE_MISMATCH" || code == "ERR_BASE_SHA256_INVALID"; + if is_base_error { + if let Some(ctx) = last_context_for_apply { + if let Ok(val) = serde_json::from_str::(validated_json_str) { + if let Some((repair, paths)) = build_v2_patch_repair_prompt_with_sha(ctx, &val) { + repair_injected_paths = paths; + apply_prompt.push_str("\n\n--- REPAIR (ERR_BASE_SHA256_NOT_FROM_CONTEXT) ---\n"); + apply_prompt.push_str(&repair); + apply_prompt.push_str("\n\nRaw output предыдущего ответа:\n"); + apply_prompt.push_str(validated_json_str); + apply_prompt.push_str("\n\n"); + } + } + } + } + // Repair-first для ERR_PATCH_APPLY_FAILED и ERR_V2_UPDATE_EXISTING_FORBIDDEN (без fallback) + if force_protocol_version != Some(1) + && (code == "ERR_PATCH_APPLY_FAILED" || code == "ERR_V2_UPDATE_EXISTING_FORBIDDEN") + { + if code == "ERR_PATCH_APPLY_FAILED" { + apply_prompt.push_str("\n\n--- REPAIR (ERR_PATCH_APPLY_FAILED) ---\n"); + apply_prompt.push_str("Увеличь контекст hunks до 3 строк, не меняй соседние блоки. Верни PATCH_FILE с исправленным patch.\n\n"); + } else if code == "ERR_V2_UPDATE_EXISTING_FORBIDDEN" { + apply_prompt.push_str("\n\n--- REPAIR (ERR_V2_UPDATE_EXISTING_FORBIDDEN) ---\n"); + apply_prompt.push_str("Сгенерируй PATCH_FILE вместо UPDATE_FILE для существующих файлов. Используй base_sha256 из контекста.\n\n"); + } + apply_prompt.push_str("Raw output предыдущего ответа:\n"); + apply_prompt.push_str(validated_json_str); + apply_prompt.push_str("\n\n"); + } + } + apply_prompt.push_str("\n\n--- РЕЖИМ APPLY ---\nПользователь подтвердил план. Применяй изменения согласно плану ниже. Верни actions с конкретными правками файлов.\n\nПЛАН:\n"); apply_prompt.push_str(plan_json); if let Some(ctx) = last_context_for_apply { apply_prompt.push_str("\n\nСОБРАННЫЙ_КОНТЕКСТ:\n"); @@ -943,12 +1236,20 @@ pub async fn plan( } } - let resp = req.send().await.map_err(|e| { - if e.is_timeout() { - log_llm_event(&trace_id, "LLM_REQUEST_TIMEOUT", &[("timeout_sec", timeout_sec.to_string())]); + let resp = match req.send().await { + Ok(r) => r, + Err(e) => { + let timeout = e.is_timeout(); + if timeout { + log_llm_event(&trace_id, "LLM_REQUEST_TIMEOUT", &[("timeout_sec", timeout_sec.to_string())]); + } + return Err(format!( + "{}: Request: {}", + if timeout { "LLM_REQUEST_TIMEOUT" } else { "LLM_REQUEST" }, + e + )); } - format!("Request: {}", e) - })?; + }; let status = resp.status(); let text = resp.text().await.map_err(|e| format!("Response body: {}", e))?; @@ -1005,7 +1306,7 @@ pub async fn plan( Err(e) => { let mut trace_val = serde_json::json!({ "trace_id": trace_id, "raw_content": content, "error": e, "event": "VALIDATION_FAILED" }); write_trace(path, &trace_id, &mut trace_val); - return Err(e); + return Err(format!("ERR_JSON_EXTRACT: {}", e)); } }; @@ -1020,7 +1321,7 @@ pub async fn plan( repair_done = true; continue; } - Err(e) => return Err(format!("JSON parse: {}", e)), + Err(e) => return Err(format!("ERR_JSON_PARSE: JSON parse: {}", e)), }; // Локальная валидация схемы (best-effort при strict выкл; обязательна при strict вкл) @@ -1036,7 +1337,7 @@ pub async fn plan( } let mut trace_val = serde_json::json!({ "trace_id": trace_id, "raw_content": content, "validated_json": json_str, "error": e, "event": "VALIDATION_FAILED" }); write_trace(path, &trace_id, &mut trace_val); - return Err(e); + return Err(format!("ERR_SCHEMA_VALIDATION: {}", e)); } let parsed = parse_plan_response(json_str)?; @@ -1102,7 +1403,7 @@ pub async fn plan( break (parsed.actions, parsed.summary_override, json_str.to_string(), user_message.clone()); }; - // Строгая валидация: path, content, конфликты, UPDATE_WITHOUT_BASE + // Строгая валидация: path, content, конфликты, UPDATE_WITHOUT_BASE, v2 UPDATE_EXISTING_FORBIDDEN if let Err(e) = validate_actions(&last_actions) { log_llm_event(&trace_id, "VALIDATION_FAILED", &[("code", "ERR_ACTIONS".to_string()), ("reason", e.clone())]); let mut trace_val = serde_json::json!({ "trace_id": trace_id, "validated_json": last_plan_json, "error": e, "event": "VALIDATION_FAILED" }); @@ -1119,6 +1420,12 @@ pub async fn plan( write_trace(path, &trace_id, &mut trace_val); return Err(e); } + if let Err(e) = validate_v2_update_existing_forbidden(project_root, &last_actions) { + log_llm_event(&trace_id, "VALIDATION_FAILED", &[("code", "ERR_V2_UPDATE_EXISTING_FORBIDDEN".to_string()), ("reason", e.clone())]); + let mut trace_val = serde_json::json!({ "trace_id": trace_id, "validated_json": last_plan_json, "error": e, "event": "VALIDATION_FAILED" }); + write_trace(path, &trace_id, &mut trace_val); + return Err(e); + } } let mode_for_plan_json = output_format_override @@ -1136,7 +1443,38 @@ pub async fn plan( "provider": provider, "actions_count": last_actions.len(), "validated_json": last_plan_json, + "protocol_default": crate::protocol::protocol_default(), }); + if let Some((_, _)) = apply_error_for_repair { + trace_val["protocol_repair_attempt"] = serde_json::json!(apply_repair_attempt.unwrap_or(0)); + } + if force_protocol_version == Some(1) { + trace_val["protocol_attempts"] = serde_json::json!(["v2", "v1"]); + trace_val["protocol_fallback_reason"] = serde_json::json!(apply_error_for_repair.as_ref().map(|(c, _)| *c).unwrap_or("unknown")); + trace_val["protocol_fallback_attempted"] = serde_json::json!(true); + trace_val["protocol_fallback_stage"] = serde_json::json!(apply_error_stage.unwrap_or("apply")); + } + if !repair_injected_paths.is_empty() { + trace_val["repair_injected_sha256"] = serde_json::json!(true); + trace_val["repair_injected_paths"] = serde_json::json!(repair_injected_paths); + } + if online_fallback_executed == Some(true) { + trace_val["online_fallback_executed"] = serde_json::json!(true); + if let Some(reason) = online_fallback_reason { + trace_val["online_fallback_reason"] = serde_json::json!(reason); + } + } + if let Some(ref r) = online_block_result { + trace_val["online_context_injected"] = serde_json::json!(true); + trace_val["online_context_chars"] = serde_json::json!(r.chars_used); + trace_val["online_context_sources_count"] = serde_json::json!(r.sources_count); + if r.was_truncated { + trace_val["online_context_truncated"] = serde_json::json!(true); + } + } + if online_context_dropped { + trace_val["online_context_dropped"] = serde_json::json!(true); + } if let Some(ref cs) = last_context_stats { trace_val["context_stats"] = serde_json::json!({ "context_files_count": cs.context_files_count, @@ -1169,19 +1507,39 @@ pub async fn plan( error_code: None, plan_json, plan_context, + protocol_version_used: Some(effective_protocol), + online_fallback_suggested: None, + online_context_used: Some(online_block_result.is_some()), }) } #[cfg(test)] mod tests { use super::{ - extract_files_read_from_plan_context, parse_actions_from_json, schema_hash, schema_hash_for_version, - validate_actions, validate_update_without_base, FIX_PLAN_SYSTEM_PROMPT, LLM_PLAN_SCHEMA_VERSION, + build_v2_patch_repair_prompt_with_sha, compiled_schema_for_version, + extract_files_read_from_plan_context, is_protocol_fallback_applicable, parse_actions_from_json, + schema_hash, schema_hash_for_version, + validate_actions, validate_update_without_base, validate_v2_update_existing_forbidden, + FIX_PLAN_SYSTEM_PROMPT, LLM_PLAN_SCHEMA_VERSION, }; use crate::types::{Action, ActionKind}; use std::fs; use std::path::Path; + #[test] + fn test_protocol_fallback_applicable() { + std::env::set_var("PAPAYU_PROTOCOL_DEFAULT", "2"); + std::env::set_var("PAPAYU_PROTOCOL_FALLBACK_TO_V1", "1"); + assert!(!is_protocol_fallback_applicable("ERR_PATCH_APPLY_FAILED", 0)); // repair-first + assert!(is_protocol_fallback_applicable("ERR_PATCH_APPLY_FAILED", 1)); + assert!(is_protocol_fallback_applicable("ERR_NON_UTF8_FILE", 0)); // immediate fallback + assert!(!is_protocol_fallback_applicable("ERR_V2_UPDATE_EXISTING_FORBIDDEN", 0)); // repair-first + assert!(is_protocol_fallback_applicable("ERR_V2_UPDATE_EXISTING_FORBIDDEN", 1)); + assert!(!is_protocol_fallback_applicable("ERR_BASE_MISMATCH", 0)); // sha repair, not fallback + std::env::remove_var("PAPAYU_PROTOCOL_DEFAULT"); + std::env::remove_var("PAPAYU_PROTOCOL_FALLBACK_TO_V1"); + } + #[test] fn test_schema_version_is_one() { assert_eq!(LLM_PLAN_SCHEMA_VERSION, 1); @@ -1218,6 +1576,13 @@ mod tests { assert_eq!(h.len(), 64); } + /// Run with: cargo test golden_traces_v2_schema_hash -- --nocapture + #[test] + #[ignore] + fn golden_traces_v2_schema_hash() { + eprintln!("v2 schema_hash: {}", schema_hash_for_version(2)); + } + #[test] fn test_validate_actions_empty() { assert!(validate_actions(&[]).is_ok()); @@ -1229,6 +1594,8 @@ mod tests { kind: ActionKind::CreateFile, path: "README.md".to_string(), content: Some("# Project".to_string()), + patch: None, + base_sha256: None, }]; assert!(validate_actions(&actions).is_ok()); } @@ -1239,6 +1606,8 @@ mod tests { kind: ActionKind::CreateFile, path: "../etc/passwd".to_string(), content: Some("x".to_string()), + patch: None, + base_sha256: None, }]; assert!(validate_actions(&actions).is_err()); } @@ -1249,6 +1618,8 @@ mod tests { kind: ActionKind::CreateFile, path: "/etc/passwd".to_string(), content: Some("x".to_string()), + patch: None, + base_sha256: None, }]; assert!(validate_actions(&actions).is_err()); } @@ -1259,6 +1630,8 @@ mod tests { kind: ActionKind::CreateFile, path: "a/..".to_string(), content: Some("x".to_string()), + patch: None, + base_sha256: None, }]; assert!(validate_actions(&actions).is_err()); } @@ -1269,6 +1642,8 @@ mod tests { kind: ActionKind::CreateFile, path: "C:/foo/bar".to_string(), content: Some("x".to_string()), + patch: None, + base_sha256: None, }]; assert!(validate_actions(&actions).is_err()); } @@ -1279,6 +1654,8 @@ mod tests { kind: ActionKind::CreateFile, path: "//server/share/file".to_string(), content: Some("x".to_string()), + patch: None, + base_sha256: None, }]; assert!(validate_actions(&actions).is_err()); } @@ -1289,6 +1666,8 @@ mod tests { kind: ActionKind::CreateDir, path: ".".to_string(), content: None, + patch: None, + base_sha256: None, }]; assert!(validate_actions(&actions).is_err()); } @@ -1299,6 +1678,8 @@ mod tests { kind: ActionKind::CreateFile, path: "a/./b".to_string(), content: Some("x".to_string()), + patch: None, + base_sha256: None, }]; assert!(validate_actions(&actions).is_err()); } @@ -1309,6 +1690,8 @@ mod tests { kind: ActionKind::CreateFile, path: "./src/main.rs".to_string(), content: Some("fn main() {}".to_string()), + patch: None, + base_sha256: None, }]; assert!(validate_actions(&actions).is_ok()); } @@ -1320,11 +1703,15 @@ mod tests { kind: ActionKind::CreateFile, path: "foo.txt".to_string(), content: Some("a".to_string()), + patch: None, + base_sha256: None, }, Action { kind: ActionKind::UpdateFile, path: "foo.txt".to_string(), content: Some("b".to_string()), + patch: None, + base_sha256: None, }, ]; assert!(validate_actions(&actions).is_err()); @@ -1337,11 +1724,15 @@ mod tests { kind: ActionKind::DeleteFile, path: "foo.txt".to_string(), content: None, + patch: None, + base_sha256: None, }, Action { kind: ActionKind::UpdateFile, path: "foo.txt".to_string(), content: Some("b".to_string()), + patch: None, + base_sha256: None, }, ]; assert!(validate_actions(&actions).is_err()); @@ -1355,6 +1746,13 @@ mod tests { assert!(paths.contains("README.md")); } + #[test] + fn test_extract_files_from_plan_context_v2_sha256() { + let ctx = "FILE[src/parser.py] (sha256=7f3f2a0c9f8b1a0c9b4c0f9e3d8a4b2d8c9e7f1a0b3c4d5e6f7a8b9c0d1e2f3a):\n1|def parse"; + let paths = extract_files_read_from_plan_context(ctx); + assert!(paths.contains("src/parser.py")); + } + #[test] fn test_validate_update_without_base_ok() { let ctx = "FILE[foo.txt]:\nold\n\n=== bar.txt ===\ncontent\n"; @@ -1363,11 +1761,15 @@ mod tests { kind: ActionKind::UpdateFile, path: "foo.txt".to_string(), content: Some("new".to_string()), + patch: None, + base_sha256: None, }, Action { kind: ActionKind::UpdateFile, path: "bar.txt".to_string(), content: Some("updated".to_string()), + patch: None, + base_sha256: None, }, ]; assert!(validate_update_without_base(&actions, Some(ctx)).is_ok()); @@ -1380,6 +1782,8 @@ mod tests { kind: ActionKind::UpdateFile, path: "unknown.txt".to_string(), content: Some("new".to_string()), + patch: None, + base_sha256: None, }]; assert!(validate_update_without_base(&actions, Some(ctx)).is_err()); } @@ -1390,6 +1794,8 @@ mod tests { kind: ActionKind::CreateFile, path: "~/etc/passwd".to_string(), content: Some("x".to_string()), + patch: None, + base_sha256: None, }]; assert!(validate_actions(&actions).is_err()); } @@ -1400,6 +1806,8 @@ mod tests { kind: ActionKind::CreateFile, path: "README.md".to_string(), content: None, + patch: None, + base_sha256: None, }]; assert!(validate_actions(&actions).is_err()); } @@ -1423,6 +1831,103 @@ mod tests { assert_eq!(actions[0].path, "src"); } + #[test] + fn test_v2_update_existing_forbidden() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); + std::env::set_var("PAPAYU_PROTOCOL_VERSION", "2"); + + let actions = vec![Action { + kind: ActionKind::UpdateFile, + path: "src/main.rs".to_string(), + content: Some("fn main() { println!(\"x\"); }\n".to_string()), + patch: None, + base_sha256: None, + }]; + let r = validate_v2_update_existing_forbidden(root, &actions); + std::env::remove_var("PAPAYU_PROTOCOL_VERSION"); + + assert!(r.is_err()); + let e = r.unwrap_err(); + assert!(e.contains("ERR_V2_UPDATE_EXISTING_FORBIDDEN")); + assert!(e.contains("PATCH_FILE")); + } + + #[test] + fn test_build_repair_prompt_injects_sha256() { + let sha = "a".repeat(64); + std::env::set_var("PAPAYU_PROTOCOL_VERSION", "2"); + let ctx = format!("FILE[src/main.rs] (sha256={}):\nfn main() {{}}\n", sha); + let validated = serde_json::json!({ + "actions": [{ + "kind": "PATCH_FILE", + "path": "src/main.rs", + "base_sha256": "wrong", + "patch": "--- a/foo\n+++ b/foo\n@@ -1,1 +1,2 @@\nold\n+new" + }] + }); + let result = build_v2_patch_repair_prompt_with_sha(&ctx, &validated); + std::env::remove_var("PAPAYU_PROTOCOL_VERSION"); + assert!(result.is_some()); + let (p, paths) = result.unwrap(); + assert!(p.contains("base_sha256")); + assert!(p.contains(&sha)); + assert!(p.contains("src/main.rs")); + assert_eq!(paths, vec!["src/main.rs"]); + } + + #[test] + fn test_repair_prompt_fallback_when_sha_missing() { + std::env::set_var("PAPAYU_PROTOCOL_VERSION", "2"); + let ctx = "FILE[src/main.rs]:\nfn main() {}\n"; + let validated = serde_json::json!({ + "actions": [{ + "kind": "PATCH_FILE", + "path": "src/main.rs", + "base_sha256": "wrong", + "patch": "--- a/foo\n+++ b/foo\n@@ -1,1 +1,2 @@\nold\n+new" + }] + }); + let result = build_v2_patch_repair_prompt_with_sha(ctx, &validated); + std::env::remove_var("PAPAYU_PROTOCOL_VERSION"); + assert!(result.is_none()); + } + + #[test] + fn test_repair_prompt_not_generated_when_base_matches() { + let sha = "b".repeat(64); + std::env::set_var("PAPAYU_PROTOCOL_VERSION", "2"); + let ctx = format!("FILE[src/foo.rs] (sha256={}):\ncontent\n", sha); + let validated = serde_json::json!({ + "actions": [{ + "kind": "PATCH_FILE", + "path": "src/foo.rs", + "base_sha256": sha, + "patch": "--- a/foo\n+++ b/foo\n@@ -1,1 +1,2 @@\ncontent\n+more" + }] + }); + let result = build_v2_patch_repair_prompt_with_sha(&ctx, &validated); + std::env::remove_var("PAPAYU_PROTOCOL_VERSION"); + assert!(result.is_none()); + } + + #[test] + fn test_parse_actions_from_json_patch_file() { + let sha = "a".repeat(64); + let actions_str = format!( + r#"[{{"kind":"PATCH_FILE","path":"src/main.rs","patch":"--- a/foo\n+++ b/foo\n@@ -1,1 +1,2 @@\nold\n+new","base_sha256":"{}"}}]"#, + sha + ); + let actions = parse_actions_from_json(&actions_str).unwrap(); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].kind, ActionKind::PatchFile); + assert_eq!(actions[0].path, "src/main.rs"); + assert!(actions[0].patch.is_some()); + assert_eq!(actions[0].base_sha256.as_deref(), Some(sha.as_str())); + } + #[test] fn golden_traces_v1_validate() { let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/golden_traces/v1"); @@ -1515,4 +2020,88 @@ mod tests { } } } + + #[test] + fn golden_traces_v2_validate() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/golden_traces/v2"); + if !dir.exists() { + return; + } + let expected_schema_hash = schema_hash_for_version(2); + let v2_schema = compiled_schema_for_version(2).expect("v2 schema must compile"); + for entry in fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let name = path.file_name().unwrap().to_string_lossy(); + let s = fs::read_to_string(&path).unwrap_or_else(|_| panic!("read {}", name)); + let v: serde_json::Value = + serde_json::from_str(&s).unwrap_or_else(|e| panic!("{}: json {}", name, e)); + + assert_eq!( + v.get("protocol") + .and_then(|p| p.get("schema_version")) + .and_then(|x| x.as_u64()), + Some(2), + "{}: schema_version must be 2", + name + ); + let sh = v + .get("protocol") + .and_then(|p| p.get("schema_hash")) + .and_then(|x| x.as_str()) + .unwrap_or(""); + assert_eq!(sh, expected_schema_hash, "{}: schema_hash", name); + + let validated = v + .get("result") + .and_then(|r| r.get("validated_json")) + .cloned() + .unwrap_or(serde_json::Value::Null); + if validated.is_null() { + continue; + } + v2_schema + .validate(&validated) + .map_err(|errs| { + let msgs: Vec = errs.map(|e| e.to_string()).collect(); + format!("{}: v2 schema validation: {}", name, msgs.join("; ")) + }) + .unwrap(); + + let validated_str = serde_json::to_string(&validated).unwrap(); + let parsed = super::parse_plan_response(&validated_str) + .unwrap_or_else(|e| panic!("{}: parse validated_json: {}", name, e)); + + if v.get("result") + .and_then(|r| r.get("validation_outcome")) + .and_then(|x| x.as_str()) + == Some("ok") + { + assert!( + validate_actions(&parsed.actions).is_ok(), + "{}: validate_actions", + name + ); + } + + let mode = v + .get("request") + .and_then(|r| r.get("mode")) + .and_then(|x| x.as_str()) + .unwrap_or(""); + if mode == "apply" && parsed.actions.is_empty() { + let summary = validated + .get("summary") + .and_then(|x| x.as_str()) + .unwrap_or(""); + assert!( + summary.starts_with("NO_CHANGES:"), + "{}: apply with empty actions requires NO_CHANGES: prefix in summary", + name + ); + } + } + } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ced25a3..9fb7f79 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -19,6 +19,7 @@ mod trends; mod undo_last; mod undo_last_tx; mod undo_status; +mod weekly_report; pub use agentic_run::agentic_run; pub use get_project_profile::get_project_profile; @@ -38,3 +39,4 @@ pub use undo_last::{get_undo_redo_state_cmd, undo_available, undo_last}; pub use undo_last_tx::undo_last_tx; pub use undo_status::undo_status; pub use settings_export::{export_settings, import_settings}; +pub use weekly_report::{analyze_weekly_reports, save_report_to_file, WeeklyReportResult}; diff --git a/src-tauri/src/commands/preview_actions.rs b/src-tauri/src/commands/preview_actions.rs index f462a02..be42724 100644 --- a/src-tauri/src/commands/preview_actions.rs +++ b/src-tauri/src/commands/preview_actions.rs @@ -1,3 +1,4 @@ +use crate::patch::{apply_unified_diff_to_text, looks_like_unified_diff, sha256_hex}; use crate::tx::safe_join; use crate::types::{ActionKind, ApplyPayload, DiffItem, PreviewResult}; use std::fs; @@ -16,6 +17,8 @@ pub fn preview_actions(payload: ApplyPayload) -> Result { old_content: Some("(blocked)".to_string()), new_content: Some("(blocked)".to_string()), summary: Some("BLOCKED: protected or non-text file".to_string()), + bytes_before: None, + bytes_after: None, }); continue; } @@ -26,6 +29,8 @@ pub fn preview_actions(payload: ApplyPayload) -> Result { old_content: None, new_content: a.content.clone(), summary: None, + bytes_before: None, + bytes_after: None, }, ActionKind::CreateDir => DiffItem { kind: "mkdir".to_string(), @@ -33,15 +38,31 @@ pub fn preview_actions(payload: ApplyPayload) -> Result { old_content: None, new_content: None, summary: None, + bytes_before: None, + bytes_after: None, }, ActionKind::UpdateFile => { let old = read_text_if_exists(root, &a.path); DiffItem { kind: "update".to_string(), path: a.path.clone(), - old_content: old, + old_content: old.clone(), new_content: a.content.clone(), summary: None, + bytes_before: old.as_ref().map(|s| s.len()), + bytes_after: a.content.as_ref().map(|s| s.len()), + } + } + ActionKind::PatchFile => { + let (diff, summary, bytes_before, bytes_after) = preview_patch_file(root, &a.path, a.patch.as_deref().unwrap_or(""), a.base_sha256.as_deref().unwrap_or("")); + DiffItem { + kind: "patch".to_string(), + path: a.path.clone(), + old_content: None, + new_content: Some(diff), + summary, + bytes_before, + bytes_after, } } ActionKind::DeleteFile => { @@ -49,9 +70,11 @@ pub fn preview_actions(payload: ApplyPayload) -> Result { DiffItem { kind: "delete".to_string(), path: a.path.clone(), - old_content: old, + old_content: old.clone(), new_content: None, summary: None, + bytes_before: old.as_ref().map(|s| s.len()), + bytes_after: None, } } ActionKind::DeleteDir => DiffItem { @@ -60,6 +83,8 @@ pub fn preview_actions(payload: ApplyPayload) -> Result { old_content: None, new_content: None, summary: None, + bytes_before: None, + bytes_after: None, }, }; diffs.push(item); @@ -74,6 +99,42 @@ pub fn preview_actions(payload: ApplyPayload) -> Result { Ok(PreviewResult { diffs, summary }) } +/// Returns (diff, summary, bytes_before, bytes_after). +fn preview_patch_file( + root: &std::path::Path, + rel: &str, + patch_text: &str, + base_sha256: &str, +) -> (String, Option, Option, Option) { + if !looks_like_unified_diff(patch_text) { + return (patch_text.to_string(), Some("ERR_PATCH_NOT_UNIFIED: patch is not unified diff".into()), None, None); + } + let p = match safe_join(root, rel) { + Ok(p) => p, + Err(_) => return (patch_text.to_string(), Some("ERR_INVALID_PATH".into()), None, None), + }; + if !p.is_file() { + return (patch_text.to_string(), Some("ERR_BASE_MISMATCH: file not found".into()), None, None); + } + let old_bytes = match fs::read(&p) { + Ok(b) => b, + Err(_) => return (patch_text.to_string(), Some("ERR_IO: cannot read file".into()), None, None), + }; + let old_sha = sha256_hex(&old_bytes); + if old_sha != base_sha256 { + return (patch_text.to_string(), Some(format!("ERR_BASE_MISMATCH: have {}, want {}", old_sha, base_sha256)), None, None); + } + let old_text = match String::from_utf8(old_bytes) { + Ok(s) => s, + Err(_) => return (patch_text.to_string(), Some("ERR_NON_UTF8_FILE: PATCH_FILE требует UTF-8. Файл не UTF-8.".into()), None, None), + }; + let bytes_before = old_text.len(); + match apply_unified_diff_to_text(&old_text, patch_text) { + Ok(new_text) => (patch_text.to_string(), None, Some(bytes_before), Some(new_text.len())), + Err(_) => (patch_text.to_string(), Some("ERR_PATCH_APPLY_FAILED: could not apply patch".into()), None, None), + } +} + fn read_text_if_exists(root: &std::path::Path, rel: &str) -> Option { let p = safe_join(root, rel).ok()?; if !p.is_file() { @@ -90,13 +151,14 @@ fn read_text_if_exists(root: &std::path::Path, rel: &str) -> Option { fn summarize(diffs: &[DiffItem]) -> String { let create = diffs.iter().filter(|d| d.kind == "create").count(); let update = diffs.iter().filter(|d| d.kind == "update").count(); + let patch = diffs.iter().filter(|d| d.kind == "patch").count(); let delete = diffs.iter().filter(|d| d.kind == "delete").count(); let mkdir = diffs.iter().filter(|d| d.kind == "mkdir").count(); let rmdir = diffs.iter().filter(|d| d.kind == "rmdir").count(); let blocked = diffs.iter().filter(|d| d.kind == "blocked").count(); let mut s = format!( - "Создать: {}, изменить: {}, удалить: {}, mkdir: {}, rmdir: {}", - create, update, delete, mkdir, rmdir + "Создать: {}, изменить: {}, patch: {}, удалить: {}, mkdir: {}, rmdir: {}", + create, update, patch, delete, mkdir, rmdir ); if blocked > 0 { s.push_str(&format!(", заблокировано: {}", blocked)); diff --git a/src-tauri/src/commands/propose_actions.rs b/src-tauri/src/commands/propose_actions.rs index 5a39c69..0ffb95b 100644 --- a/src-tauri/src/commands/propose_actions.rs +++ b/src-tauri/src/commands/propose_actions.rs @@ -4,6 +4,7 @@ use std::path::Path; +use crate::online_research; use crate::types::{Action, ActionKind, AgentPlan}; use tauri::Manager; @@ -27,6 +28,17 @@ fn has_license(root: &str) -> bool { } /// Триггеры перехода Plan→Apply (пользователь подтвердил план). +/// Извлекает префикс ошибки (ERR_XXX или LLM_REQUEST_TIMEOUT) из сообщения. +fn extract_error_code(msg: &str) -> &str { + if let Some(colon) = msg.find(':') { + let prefix = msg[..colon].trim(); + if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return prefix; + } + } + "" +} + const APPLY_TRIGGERS: &[&str] = &[ "ok", "ок", "apply", "применяй", "применить", "делай", "да", "yes", "go", "вперёд", ]; @@ -41,6 +53,15 @@ pub async fn propose_actions( trends_context: Option, last_plan_json: Option, last_context: Option, + apply_error_code: Option, + apply_error_validated_json: Option, + apply_repair_attempt: Option, + apply_error_stage: Option, + online_fallback_attempted: Option, + online_context_md: Option, + online_context_sources: Option>, + online_fallback_executed: Option, + online_fallback_reason: Option, ) -> AgentPlan { let goal_trim = user_goal.trim(); let goal_lower = goal_trim.to_lowercase(); @@ -54,6 +75,9 @@ pub async fn propose_actions( error_code: Some("PATH_NOT_FOUND".into()), plan_json: None, plan_context: None, + protocol_version_used: None, + online_fallback_suggested: None, + online_context_used: None, }; } @@ -66,10 +90,13 @@ pub async fn propose_actions( summary: String::new(), actions: vec![], error: Some(format!("app data dir: {}", e)), - error_code: Some("APP_DATA_DIR".into()), - plan_json: None, - plan_context: None, - }; + error_code: Some("APP_DATA_DIR".into()), + plan_json: None, + plan_context: None, + protocol_version_used: None, + online_fallback_suggested: None, + online_context_used: None, + }; } }; let user_prefs_path = app_data.join("papa-yu").join("preferences.json"); @@ -101,6 +128,24 @@ pub async fn propose_actions( let last_plan_ref = last_plan_json.as_deref(); let last_ctx_ref = last_context.as_deref(); + let apply_error = apply_error_code.as_deref().and_then(|code| { + apply_error_validated_json.as_deref().map(|json| (code, json)) + }); + let force_protocol = { + let code = apply_error_code.as_deref().unwrap_or(""); + let repair_attempt = apply_repair_attempt.unwrap_or(0); + if llm_planner::is_protocol_fallback_applicable(code, repair_attempt) { + let stage = apply_error_stage.as_deref().unwrap_or("apply"); + eprintln!("[trace] PROTOCOL_FALLBACK from=v2 to=v1 reason={} stage={}", code, stage); + Some(1u32) + } else { + None + } + }; + let apply_error_stage_ref = apply_error_stage.as_deref(); + let online_md_ref = online_context_md.as_deref(); + let online_sources_ref: Option<&[String]> = online_context_sources.as_deref(); + let online_reason_ref = online_fallback_reason.as_deref(); return match llm_planner::plan( &user_prefs_path, &project_prefs_path, @@ -113,19 +158,42 @@ pub async fn propose_actions( output_format_override, last_plan_ref, last_ctx_ref, + apply_error, + force_protocol, + apply_error_stage_ref, + apply_repair_attempt, + online_md_ref, + online_sources_ref, + online_fallback_executed, + online_reason_ref, ) .await { Ok(plan) => plan, - Err(e) => AgentPlan { - ok: false, - summary: String::new(), - actions: vec![], - error: Some(e), - error_code: Some("LLM_ERROR".into()), - plan_json: None, - plan_context: None, - }, + Err(e) => { + let error_code_str = extract_error_code(&e).to_string(); + let online_suggested = online_research::maybe_online_fallback( + Some(&e), + online_research::is_online_research_enabled(), + online_fallback_attempted.unwrap_or(false), + ) + .then_some(goal_trim.to_string()); + if online_suggested.is_some() { + eprintln!("[trace] ONLINE_FALLBACK_SUGGESTED error_code={} query_len={}", error_code_str, goal_trim.len()); + } + AgentPlan { + ok: false, + summary: String::new(), + actions: vec![], + error: Some(e), + error_code: Some(if error_code_str.is_empty() { "LLM_ERROR".into() } else { error_code_str }), + plan_json: None, + plan_context: None, + protocol_version_used: None, + online_fallback_suggested: online_suggested, + online_context_used: None, + } + } }; } @@ -149,6 +217,9 @@ pub async fn propose_actions( error_code: None, plan_json: None, plan_context: None, + protocol_version_used: None, + online_fallback_suggested: None, + online_context_used: None, }; } @@ -172,6 +243,8 @@ pub async fn propose_actions( "# PAPA YU Project\n\n## Цель\n{}\n\n## Как запустить\n- (добавить)\n\n## Структура\n- (добавить)\n", user_goal )), + patch: None, + base_sha256: None, }); summary.push("Добавлю README.md".into()); } @@ -183,6 +256,8 @@ pub async fn propose_actions( content: Some( "node_modules/\ndist/\nbuild/\n.DS_Store\n.env\n.env.*\ncoverage/\n.target/\n".into(), ), + patch: None, + base_sha256: None, }); summary.push("Добавлю .gitignore".into()); } @@ -202,6 +277,8 @@ pub async fn propose_actions( content: Some( "\"\"\"Точка входа. Запуск: python main.py\"\"\"\n\ndef main() -> None:\n print(\"Hello\")\n\n\nif __name__ == \"__main__\":\n main()\n".into(), ), + patch: None, + base_sha256: None, }); summary.push("Добавлю main.py (скелет)".into()); } @@ -212,6 +289,8 @@ pub async fn propose_actions( kind: ActionKind::CreateFile, path: "LICENSE".into(), content: Some("UNLICENSED\n".into()), + patch: None, + base_sha256: None, }); summary.push("Добавлю LICENSE (пометка UNLICENSED)".into()); } @@ -221,6 +300,8 @@ pub async fn propose_actions( kind: ActionKind::CreateFile, path: ".env.example".into(), content: Some("VITE_API_URL=\n# пример, без секретов\n".into()), + patch: None, + base_sha256: None, }); summary.push("Добавлю .env.example (без секретов)".into()); } @@ -234,6 +315,9 @@ pub async fn propose_actions( error_code: None, plan_json: None, plan_context: None, + protocol_version_used: None, + online_fallback_suggested: None, + online_context_used: None, }; } @@ -245,5 +329,8 @@ pub async fn propose_actions( error_code: None, plan_json: None, plan_context: None, + protocol_version_used: None, + online_fallback_suggested: None, + online_context_used: None, } } diff --git a/src-tauri/src/commands/weekly_report.rs b/src-tauri/src/commands/weekly_report.rs new file mode 100644 index 0000000..0a42ec9 --- /dev/null +++ b/src-tauri/src/commands/weekly_report.rs @@ -0,0 +1,982 @@ +//! Weekly Report Analyzer: агрегация трасс и генерация отчёта через LLM. + +use jsonschema::JSONSchema; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WeeklyStatsBundle { + pub period_from: String, + pub period_to: String, + pub apply_count: u64, + pub fallback_count: u64, + pub fallback_rate: f64, + pub fallback_by_reason: BTreeMap, + pub fallback_by_group: BTreeMap, + pub fallback_excluding_non_utf8_rate: f64, + pub repair_attempt_rate: f64, + pub repair_success_rate: f64, + pub repair_to_fallback_rate: f64, + pub sha_injection_rate: f64, + pub top_sha_injected_paths: Vec<(String, u64)>, + pub top_error_codes: Vec<(String, u64)>, + pub error_codes_by_group: BTreeMap, + pub new_error_codes: Vec<(String, u64)>, + pub context: ContextAgg, + pub cache: CacheAgg, + #[serde(skip_serializing_if = "Option::is_none")] + pub previous: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deltas: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreviousPeriodStats { + pub period_from: String, + pub period_to: String, + pub apply_count: u64, + pub fallback_count: u64, + pub fallback_rate: f64, + pub fallback_excluding_non_utf8_rate: f64, + pub repair_attempt_rate: f64, + pub repair_success_rate: f64, + pub repair_to_fallback_rate: f64, + pub sha_injection_rate: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeltaStats { + pub delta_apply_count: i64, + pub delta_fallback_count: i64, + pub delta_fallback_rate: f64, + pub delta_fallback_excluding_non_utf8_rate: f64, + pub delta_repair_attempt_rate: f64, + pub delta_repair_success_rate: f64, + pub delta_repair_to_fallback_rate: f64, + pub delta_sha_injection_rate: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextAgg { + pub avg_total_chars: f64, + pub p95_total_chars: u64, + pub avg_files_count: f64, + pub avg_dropped_files: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheAgg { + pub avg_hit_rate: f64, + pub env_hit_rate: f64, + pub read_hit_rate: f64, + pub search_hit_rate: f64, + pub logs_hit_rate: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WeeklyReportResult { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stats_bundle: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub llm_report: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub report_md: Option, +} + +/// Нормализует error_code в группу для breakdown. +fn group_error_code(code: &str) -> &'static str { + let code = code.to_uppercase(); + if code.contains("SCHEMA") || code.contains("JSON_PARSE") || code.contains("JSON_EXTRACT") || code.contains("VALIDATION") { + "LLM_FORMAT" + } else if code.contains("PATCH") || code.contains("BASE_MISMATCH") || code.contains("BASE_SHA256") { + "PATCH" + } else if code.contains("PATH") || code.contains("CONFLICT") || code.contains("PROTECTED") || code.contains("UPDATE_WITHOUT_BASE") { + "SAFETY" + } else if code.contains("NON_UTF8") || code.contains("UTF8") || code.contains("ENCODING") { + "ENCODING" + } else if code.contains("UPDATE_EXISTING") || code.contains("UPDATE_FILE") { + "V2_UPDATE" + } else { + "OTHER" + } +} + +/// Извлекает базовый ERR_ код (до двоеточия). +fn extract_base_error_code(s: &str) -> Option { + let s = s.trim(); + if s.starts_with("ERR_") { + let base = s.split(':').next().unwrap_or(s).trim().to_string(); + if !base.is_empty() { + return Some(base); + } + } + None +} + +/// Собирает error codes из golden traces (result.error_code). Ищет в project_path/docs/golden_traces и в родительских каталогах (для papa-yu repo). +fn golden_trace_error_codes(project_path: &Path) -> std::collections::HashSet { + use std::collections::HashSet; + let mut codes = HashSet::new(); + let mut search_dirs = vec![project_path.to_path_buf()]; + if let Some(parent) = project_path.parent() { + search_dirs.push(parent.to_path_buf()); + } + for base in search_dirs { + for subdir in ["v1", "v2"] { + let dir = base.join("docs").join("golden_traces").join(subdir); + if !dir.exists() { + continue; + } + let Ok(entries) = fs::read_dir(&dir) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let Ok(content) = fs::read_to_string(&path) else { continue }; + let Ok(val) = serde_json::from_str::(&content) else { continue }; + if let Some(ec) = val.get("result").and_then(|r| r.get("error_code")).and_then(|v| v.as_str()) { + if let Some(b) = extract_base_error_code(ec) { + codes.insert(b); + } + } + } + } + } + codes +} + +fn trace_to_sample(trace: &serde_json::Value) -> serde_json::Value { + let error_code = trace + .get("error_code") + .and_then(|v| v.as_str()) + .or_else(|| trace.get("error").and_then(|v| v.as_str())); + serde_json::json!({ + "event": trace.get("event"), + "error_code": error_code, + "protocol_attempts": trace.get("protocol_attempts"), + "protocol_fallback_reason": trace.get("protocol_fallback_reason"), + "protocol_repair_attempt": trace.get("protocol_repair_attempt"), + "repair_injected_paths": trace.get("repair_injected_paths"), + "actions_count": trace.get("actions_count"), + "context_stats": trace.get("context_stats"), + "cache_stats": trace.get("cache_stats"), + }) +} + +/// Собирает трассы из .papa-yu/traces за период (по mtime файла). +pub fn collect_traces( + project_path: &Path, + from_secs: u64, + to_secs: u64, +) -> Result, String> { + let traces_dir = project_path.join(".papa-yu").join("traces"); + if !traces_dir.exists() { + return Ok(vec![]); + } + let mut out = Vec::new(); + for entry in fs::read_dir(&traces_dir).map_err(|e| format!("read_dir: {}", e))? { + let entry = entry.map_err(|e| format!("read_dir entry: {}", e))?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let meta = entry.metadata().map_err(|e| format!("metadata: {}", e))?; + let mtime = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + if mtime < from_secs || mtime > to_secs { + continue; + } + let content = fs::read_to_string(&path).map_err(|e| format!("read {}: {}", path.display(), e))?; + let trace: serde_json::Value = serde_json::from_str(&content).map_err(|e| format!("parse {}: {}", path.display(), e))?; + out.push((mtime, trace)); + } + Ok(out) +} + +/// Агрегирует трассы в WeeklyStatsBundle. Без previous/deltas/new_error_codes — их добавляет analyze_weekly_reports. +pub fn aggregate_weekly( + traces: &[(u64, serde_json::Value)], + period_from: &str, + period_to: &str, +) -> WeeklyStatsBundle { + let mut apply_count: u64 = 0; + let mut fallback_count: u64 = 0; + let mut repair_attempt_count: u64 = 0; + let mut repair_to_fallback_count: u64 = 0; + let mut fallback_by_reason: BTreeMap = BTreeMap::new(); + let mut fallback_non_utf8: u64 = 0; + let mut sha_injection_count: u64 = 0; + let mut path_counts: HashMap = HashMap::new(); + let mut error_code_counts: HashMap = HashMap::new(); + let mut context_total_chars: Vec = Vec::new(); + let mut context_files_count: Vec = Vec::new(); + let mut context_dropped: Vec = Vec::new(); + let mut cache_hit_rates: Vec = Vec::new(); + let mut cache_env_hits: u64 = 0; + let mut cache_env_misses: u64 = 0; + let mut cache_read_hits: u64 = 0; + let mut cache_read_misses: u64 = 0; + let mut cache_search_hits: u64 = 0; + let mut cache_search_misses: u64 = 0; + let mut cache_logs_hits: u64 = 0; + let mut cache_logs_misses: u64 = 0; + + for (_, trace) in traces { + let event = trace.get("event").and_then(|v| v.as_str()); + if event != Some("LLM_PLAN_OK") { + if event.is_some() { + let code = trace + .get("error_code") + .and_then(|v| v.as_str()) + .or_else(|| trace.get("error").and_then(|v| v.as_str())); + if let Some(c) = code { + *error_code_counts.entry(c.to_string()).or_insert(0) += 1; + } + } + continue; + } + apply_count += 1; + + if trace.get("protocol_repair_attempt").and_then(|v| v.as_u64()) == Some(0) { + repair_attempt_count += 1; + } + if trace.get("protocol_repair_attempt").and_then(|v| v.as_u64()) == Some(1) { + let fallback_attempted = trace.get("protocol_fallback_attempted").and_then(|v| v.as_bool()).unwrap_or(false); + let reason = trace.get("protocol_fallback_reason").and_then(|v| v.as_str()).unwrap_or(""); + if !fallback_attempted || reason.is_empty() { + eprintln!( + "[trace] WEEKLY_REPORT_INVARIANT_VIOLATION protocol_repair_attempt=1 expected protocol_fallback_attempted=true and protocol_fallback_reason non-empty, got fallback_attempted={} reason_len={}", + fallback_attempted, + reason.len() + ); + } + repair_to_fallback_count += 1; + } + + if trace.get("protocol_fallback_attempted").and_then(|v| v.as_bool()).unwrap_or(false) { + fallback_count += 1; + let reason = trace + .get("protocol_fallback_reason") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + *fallback_by_reason.entry(reason.clone()).or_insert(0) += 1; + if reason == "ERR_NON_UTF8_FILE" { + fallback_non_utf8 += 1; + } + } + + if trace.get("repair_injected_sha256").and_then(|v| v.as_bool()).unwrap_or(false) { + sha_injection_count += 1; + if let Some(paths) = trace.get("repair_injected_paths").and_then(|v| v.as_array()) { + for p in paths { + if let Some(s) = p.as_str() { + *path_counts.entry(s.to_string()).or_insert(0) += 1; + } + } + } + } + + if let Some(ctx) = trace.get("context_stats") { + if let Some(n) = ctx.get("context_total_chars").and_then(|v| v.as_u64()) { + context_total_chars.push(n); + } + if let Some(n) = ctx.get("context_files_count").and_then(|v| v.as_u64()) { + context_files_count.push(n); + } + if let Some(n) = ctx.get("context_files_dropped_count").and_then(|v| v.as_u64()) { + context_dropped.push(n); + } + } + + if let Some(cache) = trace.get("cache_stats") { + if let Some(r) = cache.get("hit_rate").and_then(|v| v.as_f64()) { + cache_hit_rates.push(r); + } + cache_env_hits += cache.get("env_hits").and_then(|v| v.as_u64()).unwrap_or(0); + cache_env_misses += cache.get("env_misses").and_then(|v| v.as_u64()).unwrap_or(0); + cache_read_hits += cache.get("read_hits").and_then(|v| v.as_u64()).unwrap_or(0); + cache_read_misses += cache.get("read_misses").and_then(|v| v.as_u64()).unwrap_or(0); + cache_search_hits += cache.get("search_hits").and_then(|v| v.as_u64()).unwrap_or(0); + cache_search_misses += cache.get("search_misses").and_then(|v| v.as_u64()).unwrap_or(0); + cache_logs_hits += cache.get("logs_hits").and_then(|v| v.as_u64()).unwrap_or(0); + cache_logs_misses += cache.get("logs_misses").and_then(|v| v.as_u64()).unwrap_or(0); + } + } + + let fallback_excluding_non_utf8 = fallback_count.saturating_sub(fallback_non_utf8); + let fallback_excluding_non_utf8_rate = if apply_count > 0 { + fallback_excluding_non_utf8 as f64 / apply_count as f64 + } else { + 0.0 + }; + + let sha_injection_rate = if apply_count > 0 { + sha_injection_count as f64 / apply_count as f64 + } else { + 0.0 + }; + + let mut top_paths: Vec<(String, u64)> = path_counts.into_iter().collect(); + top_paths.sort_by(|a, b| b.1.cmp(&a.1)); + top_paths.truncate(10); + + let mut top_errors: Vec<(String, u64)> = error_code_counts.iter().map(|(k, v)| (k.clone(), *v)).collect(); + top_errors.sort_by(|a, b| b.1.cmp(&a.1)); + top_errors.truncate(10); + + let mut error_codes_by_group: BTreeMap = BTreeMap::new(); + for (code, count) in &error_code_counts { + let group = group_error_code(code).to_string(); + *error_codes_by_group.entry(group).or_insert(0) += count; + } + for (reason, count) in &fallback_by_reason { + let group = group_error_code(reason).to_string(); + *error_codes_by_group.entry(format!("fallback:{}", group)).or_insert(0) += count; + } + + let mut fallback_by_group: BTreeMap = BTreeMap::new(); + for (reason, count) in &fallback_by_reason { + let group = group_error_code(reason).to_string(); + *fallback_by_group.entry(group).or_insert(0) += count; + } + + let fallback_rate = if apply_count > 0 { + fallback_count as f64 / apply_count as f64 + } else { + 0.0 + }; + + let repair_attempt_rate = if apply_count > 0 { + repair_attempt_count as f64 / apply_count as f64 + } else { + 0.0 + }; + + let (repair_success_rate, repair_to_fallback_rate) = if repair_attempt_count > 0 { + let success_count = repair_attempt_count.saturating_sub(repair_to_fallback_count); + ( + success_count as f64 / repair_attempt_count as f64, + repair_to_fallback_count as f64 / repair_attempt_count as f64, + ) + } else { + (0.0, 0.0) + }; + + let avg_total_chars = if context_total_chars.is_empty() { + 0.0 + } else { + context_total_chars.iter().sum::() as f64 / context_total_chars.len() as f64 + }; + let mut sorted_chars = context_total_chars.clone(); + sorted_chars.sort(); + let p95_idx = (sorted_chars.len() as f64 * 0.95) as usize; + let p95_idx2 = p95_idx.min(sorted_chars.len().saturating_sub(1)); + let p95_total_chars = *sorted_chars.get(p95_idx2).unwrap_or(&0); + + let avg_files_count = if context_files_count.is_empty() { + 0.0 + } else { + context_files_count.iter().sum::() as f64 / context_files_count.len() as f64 + }; + let avg_dropped_files = if context_dropped.is_empty() { + 0.0 + } else { + context_dropped.iter().sum::() as f64 / context_dropped.len() as f64 + }; + + let avg_hit_rate = if cache_hit_rates.is_empty() { + 0.0 + } else { + cache_hit_rates.iter().sum::() / cache_hit_rates.len() as f64 + }; + let env_total = cache_env_hits + cache_env_misses; + let env_hit_rate = if env_total > 0 { + cache_env_hits as f64 / env_total as f64 + } else { + 0.0 + }; + let read_total = cache_read_hits + cache_read_misses; + let read_hit_rate = if read_total > 0 { + cache_read_hits as f64 / read_total as f64 + } else { + 0.0 + }; + let search_total = cache_search_hits + cache_search_misses; + let search_hit_rate = if search_total > 0 { + cache_search_hits as f64 / search_total as f64 + } else { + 0.0 + }; + let logs_total = cache_logs_hits + cache_logs_misses; + let logs_hit_rate = if logs_total > 0 { + cache_logs_hits as f64 / logs_total as f64 + } else { + 0.0 + }; + + WeeklyStatsBundle { + period_from: period_from.to_string(), + period_to: period_to.to_string(), + apply_count, + fallback_count, + fallback_rate, + fallback_by_reason, + fallback_by_group, + fallback_excluding_non_utf8_rate, + repair_attempt_rate, + repair_success_rate, + repair_to_fallback_rate, + sha_injection_rate, + top_sha_injected_paths: top_paths, + top_error_codes: top_errors, + error_codes_by_group, + new_error_codes: vec![], + context: ContextAgg { + avg_total_chars, + p95_total_chars, + avg_files_count, + avg_dropped_files, + }, + cache: CacheAgg { + avg_hit_rate, + env_hit_rate, + read_hit_rate, + search_hit_rate, + logs_hit_rate, + }, + previous: None, + deltas: None, + } +} + +const WEEKLY_REPORT_SYSTEM_PROMPT: &str = r#"Ты анализируешь телеметрию работы AI-агента (протоколы v1/v2). +Твоя задача: составить еженедельный отчёт для оператора с выводами и конкретными предложениями улучшений. +Никаких патчей к проекту. Никаких actions. Только отчёт по схеме. +Пиши кратко, по делу. Предлагай меры, которые оператор реально может сделать. + +ВАЖНО: Используй только предоставленные числа. Не выдумывай цифры. В evidence ссылайся на конкретные поля, например: fallback_rate_excluding_non_utf8_rate=0.012, fallback_by_reason.ERR_PATCH_APPLY_FAILED=3. + +Рекомендуемые направления: +- Снизить ERR_PATCH_APPLY_FAILED: увеличить контекст hunks/прочитать больше строк вокруг +- Снизить UPDATE_FILE violations: усилить prompt или добавить ещё один repair шаблон +- Подкрутить контекст-диету/лимиты если p95 chars часто близко к лимиту +- Расширить protected paths если видны попытки трогать секреты +- Добавить golden trace сценарий если появляется новый тип фейла"#; + +/// Вызывает LLM для генерации отчёта по агрегированным данным. +pub async fn call_llm_report( + stats: &WeeklyStatsBundle, + traces: &[(u64, serde_json::Value)], +) -> Result { + let api_url = std::env::var("PAPAYU_LLM_API_URL").map_err(|_| "PAPAYU_LLM_API_URL not set")?; + let api_url = api_url.trim(); + if api_url.is_empty() { + return Err("PAPAYU_LLM_API_URL is empty".into()); + } + let model = std::env::var("PAPAYU_LLM_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string()); + let api_key = std::env::var("PAPAYU_LLM_API_KEY").ok(); + + let schema: serde_json::Value = + serde_json::from_str(include_str!("../../config/llm_weekly_report_schema.json")) + .map_err(|e| format!("schema parse: {}", e))?; + + let stats_json = serde_json::to_string_pretty(stats).map_err(|e| format!("serialize stats: {}", e))?; + let samples: Vec = traces + .iter() + .take(5) + .map(|(_, t)| trace_to_sample(t)) + .collect(); + let samples_json = serde_json::to_string_pretty(&samples).map_err(|e| format!("serialize samples: {}", e))?; + + let user_content = format!( + "Агрегированная телеметрия за период {} — {}:\n\n```json\n{}\n```\n\nПримеры трасс (без raw_content):\n\n```json\n{}\n```", + stats.period_from, + stats.period_to, + stats_json, + samples_json + ); + + let response_format = serde_json::json!({ + "type": "json_schema", + "json_schema": { + "name": "weekly_report", + "schema": schema, + "strict": true + } + }); + + let body = serde_json::json!({ + "model": model.trim(), + "messages": [ + { "role": "system", "content": WEEKLY_REPORT_SYSTEM_PROMPT }, + { "role": "user", "content": user_content } + ], + "temperature": 0.2, + "max_tokens": 8192, + "response_format": response_format + }); + + let timeout_sec = std::env::var("PAPAYU_LLM_TIMEOUT_SEC") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(90); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_sec)) + .build() + .map_err(|e| format!("HTTP client: {}", e))?; + + let mut req = client.post(api_url).json(&body); + if let Some(ref key) = api_key { + if !key.trim().is_empty() { + req = req.header("Authorization", format!("Bearer {}", key.trim())); + } + } + + let resp = req.send().await.map_err(|e| format!("Request: {}", e))?; + let status = resp.status(); + let text = resp.text().await.map_err(|e| format!("Response: {}", e))?; + + if !status.is_success() { + return Err(format!("API error {}: {}", status, text)); + } + + let chat: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("Response JSON: {}", e))?; + let content = chat + .get("choices") + .and_then(|c| c.as_array()) + .and_then(|a| a.first()) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .ok_or_else(|| "No content in API response".to_string())?; + + let report: serde_json::Value = serde_json::from_str(content).map_err(|e| format!("Report JSON: {}", e))?; + + let compiled = JSONSchema::options() + .with_draft(jsonschema::Draft::Draft7) + .compile(&schema) + .map_err(|e| format!("Schema compile: {}", e))?; + + if let Err(e) = compiled.validate(&report) { + let msg: Vec = e.map(|ve| format!("{}", ve)).collect(); + return Err(format!("Schema validation: {}", msg.join("; "))); + } + + Ok(report) +} + +/// Собирает самодостаточный markdown: KPI-таблица и Top reasons в начале, затем текст LLM. +pub fn build_self_contained_md(stats: &WeeklyStatsBundle, llm_md: &str) -> String { + let mut md = format!( + "# Weekly Report\n\nПериод: {} — {}\n\n", + stats.period_from, stats.period_to + ); + + md.push_str("## KPI (фактические)\n\n"); + md.push_str("| Метрика | Значение |\n|--------|----------|\n"); + md.push_str(&format!("| apply_count | {} |\n", stats.apply_count)); + md.push_str(&format!("| fallback_count | {} |\n", stats.fallback_count)); + md.push_str(&format!("| fallback_rate | {:.4} |\n", stats.fallback_rate)); + md.push_str(&format!("| fallback_excluding_non_utf8_rate | {:.4} |\n", stats.fallback_excluding_non_utf8_rate)); + md.push_str(&format!("| repair_attempt_rate | {:.4} |\n", stats.repair_attempt_rate)); + md.push_str(&format!("| repair_success_rate | {:.4} |\n", stats.repair_success_rate)); + md.push_str(&format!("| repair_to_fallback_rate | {:.4} |\n", stats.repair_to_fallback_rate)); + md.push_str(&format!("| sha_injection_rate | {:.4} |\n", stats.sha_injection_rate)); + md.push_str("\n"); + + if !stats.fallback_by_reason.is_empty() { + md.push_str("## Top fallback reasons\n\n"); + md.push_str("| Причина | Кол-во |\n|---------|--------|\n"); + for (reason, count) in stats.fallback_by_reason.iter().take(10) { + md.push_str(&format!("| {} | {} |\n", reason, count)); + } + md.push_str("\n"); + } + + if !stats.fallback_by_group.is_empty() { + md.push_str("## Fallback по группам\n\n"); + md.push_str("| Группа | Кол-во |\n|--------|--------|\n"); + for (group, count) in &stats.fallback_by_group { + md.push_str(&format!("| {} | {} |\n", group, count)); + } + md.push_str("\n"); + } + + if !stats.new_error_codes.is_empty() { + md.push_str("## Новые error codes (кандидаты на golden trace)\n\n"); + for (code, count) in &stats.new_error_codes { + md.push_str(&format!("- {} ({} раз)\n", code, count)); + } + md.push_str("\n"); + } + + if let Some(ref deltas) = stats.deltas { + md.push_str("## Дельты vs предыдущая неделя\n\n"); + md.push_str(&format!("| delta_apply_count | {} |\n", deltas.delta_apply_count)); + md.push_str(&format!("| delta_fallback_rate | {:+.4} |\n", deltas.delta_fallback_rate)); + md.push_str(&format!("| delta_repair_attempt_rate | {:+.4} |\n", deltas.delta_repair_attempt_rate)); + md.push_str(&format!("| delta_repair_success_rate | {:+.4} |\n", deltas.delta_repair_success_rate)); + md.push_str("\n"); + } + + md.push_str("---\n\n"); + md.push_str(llm_md); + md +} + +/// Формирует Markdown отчёт из LLM ответа. +pub fn report_to_md(report: &serde_json::Value) -> String { + let title = report.get("title").and_then(|v| v.as_str()).unwrap_or("Weekly Report"); + let period = report.get("period"); + let from = period.and_then(|p| p.get("from")).and_then(|v| v.as_str()).unwrap_or("?"); + let to = period.and_then(|p| p.get("to")).and_then(|v| v.as_str()).unwrap_or("?"); + let summary = report.get("summary_md").and_then(|v| v.as_str()).unwrap_or(""); + + let mut md = format!("# {}\n\nПериод: {} — {}\n\n{}\n\n", title, from, to, summary); + + if let Some(kpis) = report.get("kpis") { + md.push_str("## KPI\n\n"); + md.push_str("| Метрика | Значение |\n|--------|----------|\n"); + for (key, val) in kpis.as_object().unwrap_or(&serde_json::Map::new()) { + let v = match val { + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::String(s) => s.clone(), + _ => format!("{:?}", val), + }; + md.push_str(&format!("| {} | {} |\n", key, v)); + } + md.push_str("\n"); + } + + if let Some(findings) = report.get("findings").and_then(|v| v.as_array()) { + md.push_str("## Выводы\n\n"); + for f in findings { + let sev = f.get("severity").and_then(|v| v.as_str()).unwrap_or("info"); + let title_f = f.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let ev = f.get("evidence").and_then(|v| v.as_str()).unwrap_or(""); + md.push_str(&format!("- **{}** [{}]: {}\n", title_f, sev, ev)); + } + md.push_str("\n"); + } + + if let Some(recs) = report.get("recommendations").and_then(|v| v.as_array()) { + md.push_str("## Рекомендации\n\n"); + for r in recs { + let pri = r.get("priority").and_then(|v| v.as_str()).unwrap_or("p2"); + let title_r = r.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let rat = r.get("rationale").and_then(|v| v.as_str()).unwrap_or(""); + md.push_str(&format!("- [{}] **{}**: {} — {}\n", pri, title_r, rat, r.get("expected_impact").and_then(|v| v.as_str()).unwrap_or(""))); + } + md.push_str("\n"); + } + + if let Some(actions) = report.get("operator_actions").and_then(|v| v.as_array()) { + md.push_str("## Действия оператора\n\n"); + for a in actions { + let title_a = a.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let empty: Vec = vec![]; + let steps = a.get("steps").and_then(|v| v.as_array()).unwrap_or(&empty); + let est = a.get("time_estimate_minutes").and_then(|v| v.as_i64()).unwrap_or(0); + md.push_str(&format!("### {}\n\nОценка: {} мин\n\n", title_a, est)); + for (i, s) in steps.iter().enumerate() { + if let Some(st) = s.as_str() { + md.push_str(&format!("{}. {}\n", i + 1, st)); + } + } + md.push_str("\n"); + } + } + + md +} + +/// Анализирует трассы и генерирует еженедельный отчёт. +pub async fn analyze_weekly_reports( + project_path: &Path, + from: Option, + to: Option, +) -> WeeklyReportResult { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::from_secs(0)); + let now_secs = now.as_secs(); + let week_secs: u64 = 7 * 24 * 3600; + let (to_secs, from_secs) = if let (Some(f), Some(t)) = (&from, &to) { + let from_secs = chrono_parse_or_default(f, now_secs.saturating_sub(week_secs)); + let to_secs = chrono_parse_or_default(t, now_secs); + (to_secs, from_secs) + } else { + (now_secs, now_secs.saturating_sub(week_secs)) + }; + + let traces = match collect_traces(project_path, from_secs, to_secs) { + Ok(t) => t, + Err(e) => { + return WeeklyReportResult { + ok: false, + error: Some(e), + stats_bundle: None, + llm_report: None, + report_md: None, + }; + } + }; + + let from_str = format_timestamp(from_secs); + let to_str = format_timestamp(to_secs); + let period_secs = to_secs.saturating_sub(from_secs); + let prev_from_secs = from_secs.saturating_sub(period_secs); + let prev_to_secs = from_secs; + let prev_from_str = format_timestamp(prev_from_secs); + let prev_to_str = format_timestamp(prev_to_secs); + + let mut stats = aggregate_weekly(&traces, &from_str, &to_str); + + let prev_traces = collect_traces(project_path, prev_from_secs, prev_to_secs).unwrap_or_default(); + if !prev_traces.is_empty() { + let prev_stats = aggregate_weekly(&prev_traces, &prev_from_str, &prev_to_str); + stats.previous = Some(PreviousPeriodStats { + period_from: prev_stats.period_from, + period_to: prev_stats.period_to, + apply_count: prev_stats.apply_count, + fallback_count: prev_stats.fallback_count, + fallback_rate: prev_stats.fallback_rate, + fallback_excluding_non_utf8_rate: prev_stats.fallback_excluding_non_utf8_rate, + repair_attempt_rate: prev_stats.repair_attempt_rate, + repair_success_rate: prev_stats.repair_success_rate, + repair_to_fallback_rate: prev_stats.repair_to_fallback_rate, + sha_injection_rate: prev_stats.sha_injection_rate, + }); + stats.deltas = Some(DeltaStats { + delta_apply_count: stats.apply_count as i64 - prev_stats.apply_count as i64, + delta_fallback_count: stats.fallback_count as i64 - prev_stats.fallback_count as i64, + delta_fallback_rate: stats.fallback_rate - prev_stats.fallback_rate, + delta_fallback_excluding_non_utf8_rate: stats.fallback_excluding_non_utf8_rate - prev_stats.fallback_excluding_non_utf8_rate, + delta_repair_attempt_rate: stats.repair_attempt_rate - prev_stats.repair_attempt_rate, + delta_repair_success_rate: stats.repair_success_rate - prev_stats.repair_success_rate, + delta_repair_to_fallback_rate: stats.repair_to_fallback_rate - prev_stats.repair_to_fallback_rate, + delta_sha_injection_rate: stats.sha_injection_rate - prev_stats.sha_injection_rate, + }); + } + + let golden = golden_trace_error_codes(project_path); + let mut new_counts: HashMap = HashMap::new(); + for (code, count) in stats + .top_error_codes + .iter() + .map(|(k, v)| (k.as_str(), *v)) + .chain(stats.fallback_by_reason.iter().map(|(k, v)| (k.as_str(), *v))) + { + if let Some(base) = extract_base_error_code(code) { + if !golden.contains(&base) { + *new_counts.entry(base).or_insert(0) += count; + } + } + } + let mut new_errors: Vec<(String, u64)> = new_counts.into_iter().collect(); + new_errors.sort_by(|a, b| b.1.cmp(&a.1)); + stats.new_error_codes = new_errors; + + if traces.is_empty() { + let report_md = format!( + "# Weekly Report\n\nПериод: {} — {}\n\nТрасс за период не найдено. Включи PAPAYU_TRACE=1 и выполни несколько операций.", + from_str, to_str + ); + return WeeklyReportResult { + ok: true, + error: None, + stats_bundle: Some(stats), + llm_report: None, + report_md: Some(report_md), + }; + } + + match call_llm_report(&stats, &traces).await { + Ok(report) => { + let llm_md = report_to_md(&report); + let report_md = build_self_contained_md(&stats, &llm_md); + WeeklyReportResult { + ok: true, + error: None, + stats_bundle: Some(stats), + llm_report: Some(report), + report_md: Some(report_md), + } + } + Err(e) => WeeklyReportResult { + ok: false, + error: Some(e), + stats_bundle: Some(stats), + llm_report: None, + report_md: None, + }, + } +} + +fn chrono_parse_or_default(s: &str, default: u64) -> u64 { + use chrono::{NaiveDate, NaiveDateTime}; + let s = s.trim(); + if s.is_empty() { + return default; + } + for fmt in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"] { + if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) { + return dt.and_utc().timestamp() as u64; + } + } + if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { + if let Some(dt) = d.and_hms_opt(0, 0, 0) { + return dt.and_utc().timestamp() as u64; + } + } + default +} + +fn format_timestamp(secs: u64) -> String { + use chrono::{DateTime, Utc}; + let dt = DateTime::::from_timestamp_secs(secs as i64) + .unwrap_or_else(|| DateTime::::from_timestamp_secs(0).unwrap()); + dt.format("%Y-%m-%d").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_aggregate_weekly_empty() { + let traces: Vec<(u64, serde_json::Value)> = vec![]; + let stats = aggregate_weekly(&traces, "2024-01-01", "2024-01-07"); + assert_eq!(stats.apply_count, 0); + assert_eq!(stats.fallback_count, 0); + assert_eq!(stats.fallback_excluding_non_utf8_rate, 0.0); + assert_eq!(stats.repair_success_rate, 0.0); + assert_eq!(stats.sha_injection_rate, 0.0); + } + + #[test] + fn test_aggregate_weekly_llm_plan_ok() { + let traces = vec![ + ( + 1704067200, // 2024-01-01: repair attempt that succeeded (no fallback) + serde_json::json!({ + "event": "LLM_PLAN_OK", + "protocol_repair_attempt": 0, + "actions_count": 2, + "context_stats": { "context_total_chars": 1000, "context_files_count": 1, "context_files_dropped_count": 0 }, + "cache_stats": { "hit_rate": 0.5, "env_hits": 0, "env_misses": 1, "read_hits": 1, "read_misses": 0, "search_hits": 0, "search_misses": 0, "logs_hits": 0, "logs_misses": 0 } + }), + ), + ( + 1704153600, // repair failed → fallback plan + serde_json::json!({ + "event": "LLM_PLAN_OK", + "protocol_repair_attempt": 1, + "protocol_fallback_attempted": true, + "protocol_fallback_reason": "ERR_PATCH_APPLY_FAILED", + "actions_count": 1, + "context_stats": { "context_total_chars": 500, "context_files_count": 1, "context_files_dropped_count": 0 }, + "cache_stats": { "hit_rate": 0.6, "env_hits": 1, "env_misses": 0, "read_hits": 1, "read_misses": 0, "search_hits": 0, "search_misses": 0, "logs_hits": 0, "logs_misses": 0 } + }), + ), + ]; + let stats = aggregate_weekly(&traces, "2024-01-01", "2024-01-07"); + assert_eq!(stats.apply_count, 2); + assert_eq!(stats.fallback_count, 1); + assert!((stats.fallback_excluding_non_utf8_rate - 0.5).abs() < 0.001); + assert!((stats.repair_attempt_rate - 0.5).abs() < 0.001); // 1 repair attempt / 2 applies + assert!((stats.repair_success_rate - 0.0).abs() < 0.001); // 0/1 repair attempts succeeded + assert!((stats.repair_to_fallback_rate - 1.0).abs() < 0.001); // 1/1 went to fallback + assert_eq!(stats.fallback_by_reason.get("ERR_PATCH_APPLY_FAILED"), Some(&1)); + } + + #[test] + fn test_group_error_code() { + assert_eq!(group_error_code("ERR_SCHEMA_VALIDATION"), "LLM_FORMAT"); + assert_eq!(group_error_code("ERR_JSON_PARSE"), "LLM_FORMAT"); + assert_eq!(group_error_code("ERR_PATCH_APPLY_FAILED"), "PATCH"); + assert_eq!(group_error_code("ERR_BASE_MISMATCH"), "PATCH"); + assert_eq!(group_error_code("ERR_NON_UTF8_FILE"), "ENCODING"); + assert_eq!(group_error_code("ERR_INVALID_PATH"), "SAFETY"); + assert_eq!(group_error_code("ERR_V2_UPDATE_EXISTING_FORBIDDEN"), "V2_UPDATE"); + } + + #[test] + fn test_build_self_contained_md() { + let stats = WeeklyStatsBundle { + period_from: "2024-01-01".into(), + period_to: "2024-01-07".into(), + apply_count: 10, + fallback_count: 1, + fallback_rate: 0.1, + fallback_by_reason: [("ERR_PATCH_APPLY_FAILED".into(), 1)].into_iter().collect(), + fallback_by_group: [("PATCH".into(), 1)].into_iter().collect(), + fallback_excluding_non_utf8_rate: 0.1, + repair_attempt_rate: 0.2, + repair_success_rate: 0.9, + repair_to_fallback_rate: 0.1, + sha_injection_rate: 0.05, + top_sha_injected_paths: vec![], + top_error_codes: vec![], + error_codes_by_group: [("PATCH".into(), 1)].into_iter().collect(), + new_error_codes: vec![("ERR_XYZ".into(), 2)], + context: ContextAgg { avg_total_chars: 0.0, p95_total_chars: 0, avg_files_count: 0.0, avg_dropped_files: 0.0 }, + cache: CacheAgg { avg_hit_rate: 0.0, env_hit_rate: 0.0, read_hit_rate: 0.0, search_hit_rate: 0.0, logs_hit_rate: 0.0 }, + previous: None, + deltas: None, + }; + let md = build_self_contained_md(&stats, "## LLM Summary\n\nText."); + assert!(md.contains("apply_count")); + assert!(md.contains("ERR_PATCH_APPLY_FAILED")); + assert!(md.contains("ERR_XYZ")); + assert!(md.contains("LLM Summary")); + } + + #[test] + fn test_report_to_md() { + let report = serde_json::json!({ + "title": "Test Report", + "period": { "from": "2024-01-01", "to": "2024-01-07" }, + "summary_md": "Summary text.", + "kpis": { "apply_count": 10, "fallback_count": 1 }, + "findings": [{ "severity": "info", "title": "Finding 1", "evidence": "Evidence 1" }], + "recommendations": [{ "priority": "p1", "title": "Rec 1", "rationale": "Why", "expected_impact": "Impact" }], + "operator_actions": [{ "title": "Action 1", "steps": ["Step 1"], "time_estimate_minutes": 5 }] + }); + let md = report_to_md(&report); + assert!(md.contains("# Test Report")); + assert!(md.contains("Summary text.")); + assert!(md.contains("apply_count")); + assert!(md.contains("Finding 1")); + assert!(md.contains("Rec 1")); + assert!(md.contains("Action 1")); + } +} + +/// Сохраняет отчёт в docs/reports/weekly_YYYY-MM-DD.md. +pub fn save_report_to_file( + project_path: &Path, + report_md: &str, + date: Option<&str>, +) -> Result { + let date_str = date + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| chrono::Utc::now().format("%Y-%m-%d").to_string()); + let reports_dir = project_path.join("docs").join("reports"); + fs::create_dir_all(&reports_dir).map_err(|e| format!("create_dir: {}", e))?; + let file_path = reports_dir.join(format!("weekly_{}.md", date_str)); + fs::write(&file_path, report_md).map_err(|e| format!("write: {}", e))?; + Ok(file_path.to_string_lossy().to_string()) +} diff --git a/src-tauri/src/context.rs b/src-tauri/src/context.rs index 6793e36..858b535 100644 --- a/src-tauri/src/context.rs +++ b/src-tauri/src/context.rs @@ -1,11 +1,17 @@ //! Автосбор контекста для LLM: env, project prefs, context_requests (read_file, search, logs). //! Кеш read/search/logs/env в пределах сессии (plan-цикла). +//! Protocol v2: FILE[path] (sha256=...) для base_sha256 в PATCH_FILE. use crate::memory::EngineeringMemory; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::fs; use std::path::Path; +fn protocol_version() -> u32 { + crate::protocol::protocol_version(None) +} + const MAX_CONTEXT_LINE_LEN: usize = 80_000; const SEARCH_MAX_HITS: usize = 50; @@ -23,7 +29,7 @@ fn context_max_file_chars() -> usize { .unwrap_or(20_000) } -fn context_max_total_chars() -> usize { +pub fn context_max_total_chars() -> usize { std::env::var("PAPAYU_CONTEXT_MAX_TOTAL_CHARS") .ok() .and_then(|s| s.trim().parse().ok()) @@ -184,6 +190,7 @@ pub struct FulfillResult { /// Выполняет context_requests от модели и возвращает текст для добавления в user message. /// Использует кеш, если передан; логирует CONTEXT_CACHE_HIT/MISS при trace_id. +/// При protocol_version=2 добавляет sha256 в FILE-блоки: FILE[path] (sha256=...). pub fn fulfill_context_requests( project_root: &Path, requests: &[serde_json::Value], @@ -191,6 +198,7 @@ pub fn fulfill_context_requests( mut cache: Option<&mut ContextCache>, trace_id: Option<&str>, ) -> FulfillResult { + let include_sha256 = protocol_version() == 2; let mut parts = Vec::new(); let mut logs_chars: usize = 0; for r in requests { @@ -222,8 +230,12 @@ pub fn fulfill_context_requests( v } else { c.cache_stats.read_misses += 1; - let v = read_file_snippet(project_root, path, start as usize, end as usize); - let out = format!("FILE[{}]:\n{}", path, v); + let (snippet, sha) = read_file_snippet_with_sha256(project_root, path, start as usize, end as usize); + let out = if include_sha256 && !sha.is_empty() { + format!("FILE[{}] (sha256={}):\n{}", path, sha, snippet) + } else { + format!("FILE[{}]:\n{}", path, snippet) + }; if let Some(tid) = trace_id { eprintln!("[{}] CONTEXT_CACHE_MISS key=read_file path={} size={}", tid, path, out.len()); } @@ -231,8 +243,12 @@ pub fn fulfill_context_requests( out } } else { - let v = read_file_snippet(project_root, path, start as usize, end as usize); - format!("FILE[{}]:\n{}", path, v) + let (snippet, sha) = read_file_snippet_with_sha256(project_root, path, start as usize, end as usize); + if include_sha256 && !sha.is_empty() { + format!("FILE[{}] (sha256={}):\n{}", path, sha, snippet) + } else { + format!("FILE[{}]:\n{}", path, snippet) + } }; parts.push(content); } @@ -408,6 +424,51 @@ pub fn fulfill_context_requests( } } +/// Читает файл и возвращает (snippet, sha256_hex). sha256 — от полного содержимого файла. +fn read_file_snippet_with_sha256( + root: &Path, + rel_path: &str, + start_line: usize, + end_line: usize, +) -> (String, String) { + let path = root.join(rel_path); + if !path.is_file() { + return (format!("(файл не найден: {})", rel_path), String::new()); + } + let full_content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return ("(не удалось прочитать)".to_string(), String::new()), + }; + let sha256_hex = { + let mut hasher = Sha256::new(); + hasher.update(full_content.as_bytes()); + format!("{:x}", hasher.finalize()) + }; + let lines: Vec<&str> = full_content.lines().collect(); + let start = start_line.saturating_sub(1).min(lines.len()); + let end = end_line.min(lines.len()).max(start); + let slice: Vec<&str> = lines.get(start..end).unwrap_or(&[]).into_iter().copied().collect(); + let mut out = String::new(); + for (i, line) in slice.iter().enumerate() { + let line_no = start + i + 1; + out.push_str(&format!("{}|{}\n", line_no, line)); + } + let max_chars = context_max_file_chars().min(MAX_CONTEXT_LINE_LEN); + let snippet = if out.len() > max_chars { + let head = (max_chars as f32 * 0.6) as usize; + let tail = max_chars - head - 30; + format!( + "{}...[TRUNCATED {} chars]...\n{}", + &out[..head.min(out.len())], + out.len(), + &out[out.len().saturating_sub(tail)..] + ) + } else { + out + }; + (snippet, sha256_hex) +} + fn read_file_snippet(root: &Path, rel_path: &str, start_line: usize, end_line: usize) -> String { let path = root.join(rel_path); if !path.is_file() { @@ -551,6 +612,37 @@ pub fn gather_auto_context_from_message(project_root: &Path, user_message: &str) } } +/// Извлекает path → sha256 из контекста (FILE[path] (sha256=...):). Для диагностики и repair. +pub fn extract_file_sha256_from_context(context: &str) -> std::collections::HashMap { + use std::collections::HashMap; + let mut m = HashMap::new(); + for line in context.lines() { + if !line.starts_with("FILE[") { + continue; + } + let close = match line.find(']') { + Some(i) => i, + None => continue, + }; + let path = &line[5..close]; + let sha_tag = "(sha256="; + let sha_pos = match line.find(sha_tag) { + Some(i) => i, + None => continue, + }; + let sha_start = sha_pos + sha_tag.len(); + let sha_end = match line[sha_start..].find(')') { + Some(j) => sha_start + j, + None => continue, + }; + let sha = &line[sha_start..sha_end]; + if sha.len() == 64 && sha.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { + m.insert(path.to_string(), sha.to_string()); + } + } + m +} + /// Извлекает пути и строки из traceback в тексте (Python). Используется при автосборе контекста по ошибке. pub fn extract_traceback_files(text: &str) -> Vec<(String, usize)> { let mut out = Vec::new(); @@ -661,4 +753,47 @@ mod tests { assert!(files[0].0.contains("main.py")); assert_eq!(files[0].1, 42); } + + #[test] + fn test_extract_file_sha256_from_context() { + let ctx = r#"FILE[src/parser.py] (sha256=7f3f2a0c9f8b1a0c9b4c0f9e3d8a4b2d8c9e7f1a0b3c4d5e6f7a8b9c0d1e2f3a): +1|def parse + +FILE[src/main.rs]: +fn main() {}"#; + let m = extract_file_sha256_from_context(ctx); + assert_eq!(m.len(), 1); + assert_eq!(m.get("src/parser.py").map(|s| s.as_str()), Some("7f3f2a0c9f8b1a0c9b4c0f9e3d8a4b2d8c9e7f1a0b3c4d5e6f7a8b9c0d1e2f3a")); + // src/main.rs без sha256 — не попадёт + assert!(m.get("src/main.rs").is_none()); + + let sha_a = "a".repeat(64); + let sha_b = "b".repeat(64); + let ctx2a = format!("FILE[a.py] (sha256={}):\ncontent\n", sha_a); + let ctx2b = format!("FILE[b.rs] (sha256={}):\ncontent\n", sha_b); + let m2a = extract_file_sha256_from_context(&ctx2a); + let m2b = extract_file_sha256_from_context(&ctx2b); + assert_eq!(m2a.len(), 1); + assert_eq!(m2b.len(), 1); + assert_eq!(m2a.get("a.py").map(|s| s.len()), Some(64)); + assert_eq!(m2b.get("b.rs").map(|s| s.len()), Some(64)); + } + + #[test] + fn test_render_file_block_v2_includes_sha256() { + use std::fs; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); + std::env::set_var("PAPAYU_PROTOCOL_VERSION", "2"); + let reqs = vec![serde_json::json!({"type": "read_file", "path": "src/main.rs", "start_line": 1, "end_line": 10})]; + let result = fulfill_context_requests(root, &reqs, 200, None, None); + std::env::remove_var("PAPAYU_PROTOCOL_VERSION"); + assert!(result.content.contains("FILE[src/main.rs] (sha256=")); + assert!(result.content.contains("):")); + let m = extract_file_sha256_from_context(&result.content); + assert_eq!(m.len(), 1); + assert_eq!(m.get("src/main.rs").map(|s| s.len()), Some(64)); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a28ab26..10f69f0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,15 @@ mod commands; mod context; +mod online_research; mod memory; +mod patch; +mod protocol; mod store; mod tx; mod types; mod verify; -use commands::{add_project, agentic_run, analyze_project, append_session_event, apply_actions, apply_actions_tx, export_settings, fetch_trends_recommendations, generate_actions, generate_actions_from_report, get_project_profile, get_project_settings, get_trends_recommendations, get_undo_redo_state_cmd, import_settings, list_projects, list_sessions, load_folder_links, preview_actions, propose_actions, redo_last, run_batch, save_folder_links, set_project_settings, undo_available, undo_last, undo_last_tx, undo_status}; +use commands::{add_project, agentic_run, analyze_project, analyze_weekly_reports, append_session_event, apply_actions, apply_actions_tx, export_settings, fetch_trends_recommendations, generate_actions, generate_actions_from_report, get_project_profile, get_project_settings, get_trends_recommendations, get_undo_redo_state_cmd, import_settings, list_projects, list_sessions, load_folder_links, preview_actions, propose_actions, redo_last, run_batch, save_folder_links, save_report_to_file, set_project_settings, undo_available, undo_last, undo_last_tx, undo_status}; use tauri::Manager; use commands::FolderLinks; use types::{ApplyPayload, BatchPayload}; @@ -49,6 +52,32 @@ fn verify_project(path: String) -> types::VerifyResult { verify::verify_project(&path) } +/// Анализ еженедельных отчётов: агрегация трасс и генерация отчёта через LLM. +#[tauri::command] +async fn analyze_weekly_reports_cmd( + project_path: String, + from: Option, + to: Option, +) -> commands::WeeklyReportResult { + analyze_weekly_reports(std::path::Path::new(&project_path), from, to).await +} + +/// Online research: поиск + fetch + LLM summarize. +#[tauri::command] +async fn research_answer_cmd(query: String) -> Result { + online_research::research_answer(&query).await +} + +/// Сохранить отчёт в docs/reports/weekly_YYYY-MM-DD.md. +#[tauri::command] +fn save_report_cmd(project_path: String, report_md: String, date: Option) -> Result { + save_report_to_file( + std::path::Path::new(&project_path), + &report_md, + date.as_deref(), + ) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -84,6 +113,9 @@ pub fn run() { fetch_trends_recommendations, export_settings, import_settings, + analyze_weekly_reports_cmd, + save_report_cmd, + research_answer_cmd, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/online_research/extract.rs b/src-tauri/src/online_research/extract.rs new file mode 100644 index 0000000..01c221a --- /dev/null +++ b/src-tauri/src/online_research/extract.rs @@ -0,0 +1,120 @@ +//! Извлечение текста из HTML. + +use scraper::{Html, Selector}; + +pub(crate) const MAX_CHARS: usize = 40_000; + +/// Извлекает текст из HTML: убирает script/style, берёт body, нормализует пробелы. +pub fn extract_text(html: &str) -> String { + let doc = Html::parse_document(html); + let body_html = match Selector::parse("body") { + Ok(s) => doc.select(&s).next().map(|el| el.html()), + Err(_) => None, + }; + let fragment = body_html.unwrap_or_else(|| doc.root_element().html()); + + let without_script = remove_tag_content(&fragment, "script"); + let without_style = remove_tag_content(&without_script, "style"); + let without_noscript = remove_tag_content(&without_style, "noscript"); + let cleaned = strip_tags_simple(&without_noscript); + let normalized = normalize_whitespace(&cleaned); + truncate_to(&normalized, MAX_CHARS) +} + +fn remove_tag_content(html: &str, tag: &str) -> String { + let open = format!("<{}", tag); + let close = format!("", tag); + let mut out = String::with_capacity(html.len()); + let mut i = 0; + let bytes = html.as_bytes(); + while i < bytes.len() { + if let Some(start) = find_ignore_case(bytes, i, &open) { + let after_open = start + open.len(); + if let Some(end) = find_ignore_case(bytes, after_open, &close) { + out.push_str(&html[i..start]); + i = end + close.len(); + continue; + } + } + if i < bytes.len() { + out.push(html.chars().nth(i).unwrap_or(' ')); + i += 1; + } + } + if out.is_empty() { + html.to_string() + } else { + out + } +} + +fn find_ignore_case(haystack: &[u8], start: usize, needle: &str) -> Option { + let needle_bytes = needle.as_bytes(); + haystack[start..] + .windows(needle_bytes.len()) + .position(|w| w.eq_ignore_ascii_case(needle_bytes)) + .map(|p| start + p) +} + +fn strip_tags_simple(html: &str) -> String { + let doc = Html::parse_fragment(html); + let root = doc.root_element(); + let mut text = root.text().collect::>().join(" "); + text = text.replace("\u{a0}", " "); + text +} + +fn normalize_whitespace(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut prev_space = false; + for c in s.chars() { + if c.is_whitespace() { + if !prev_space { + out.push(' '); + prev_space = true; + } + } else { + out.push(c); + prev_space = false; + } + } + out.trim().to_string() +} + +pub(crate) fn truncate_to(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + s.chars().take(max).collect::() + "..." + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_text_basic() { + let html = r#"

Title

Paragraph text.

"#; + let t = extract_text(html); + assert!(t.contains("Title")); + assert!(t.contains("Paragraph")); + } + + #[test] + fn test_extract_removes_script() { + let html = r#"

Hello

World

"#; + let t = extract_text(html); + assert!(!t.contains("alert")); + assert!(t.contains("Hello")); + assert!(t.contains("World")); + } + + #[test] + fn test_truncate_to() { + let s = "a".repeat(50_000); + let t = super::truncate_to(&s, super::MAX_CHARS); + assert!(t.ends_with("...")); + assert!(t.chars().count() <= super::MAX_CHARS + 3); + } +} diff --git a/src-tauri/src/online_research/fallback.rs b/src-tauri/src/online_research/fallback.rs new file mode 100644 index 0000000..cba6c4d --- /dev/null +++ b/src-tauri/src/online_research/fallback.rs @@ -0,0 +1,130 @@ +//! Decision layer for online fallback. + +/// Триггеры online fallback. +const ONLINE_FALLBACK_ERROR_CODES: &[&str] = &[ + "LLM_REQUEST_TIMEOUT", + "ERR_JSON_PARSE", + "ERR_JSON_EXTRACT", + "ERR_SCHEMA_VALIDATION", +]; + +/// Решает, нужно ли предлагать online fallback по ошибке PRIMARY. +/// +/// Triggers: timeout, ERR_JSON_PARSE/ERR_JSON_EXTRACT/ERR_SCHEMA_VALIDATION after repair, +/// или явный NEEDS_ONLINE_RESEARCH в summary/context_requests. +/// +/// Ограничение: один раз на запрос (online_fallback_already_attempted). +pub fn maybe_online_fallback( + error_message: Option<&str>, + online_enabled: bool, + online_fallback_already_attempted: bool, +) -> bool { + if !online_enabled || online_fallback_already_attempted { + return false; + } + let msg = match error_message { + Some(m) => m, + None => return false, + }; + let code = extract_error_code_prefix(msg); + ONLINE_FALLBACK_ERROR_CODES.contains(&code) +} + +/// Извлекает префикс вида "ERR_XXX:" или "LLM_REQUEST_TIMEOUT:" из сообщения. +pub fn extract_error_code_prefix(msg: &str) -> &str { + if let Some(colon) = msg.find(':') { + let prefix = msg[..colon].trim(); + if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return prefix; + } + } + "" +} + +/// Проверяет наличие NEEDS_ONLINE_RESEARCH или ONLINE: в summary/context_requests. +#[allow(dead_code)] +pub fn extract_needs_online_from_plan(summary: Option<&str>, context_requests_json: Option<&str>) -> Option { + if let Some(s) = summary { + if let Some(q) = extract_online_query_from_text(s) { + return Some(q); + } + } + if let Some(json) = context_requests_json { + if let Ok(arr) = serde_json::from_str::>(json) { + for req in arr { + if let Some(obj) = req.as_object() { + let ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let query = obj.get("query").and_then(|v| v.as_str()).unwrap_or(""); + if ty == "search" && query.starts_with("ONLINE:") { + let q = query.strip_prefix("ONLINE:").map(|s| s.trim()).unwrap_or(query).to_string(); + if !q.is_empty() { + return Some(q); + } + } + } + } + } + } + None +} + +#[allow(dead_code)] +fn extract_online_query_from_text(s: &str) -> Option { + if let Some(idx) = s.find("NEEDS_ONLINE_RESEARCH:") { + let rest = &s[idx + "NEEDS_ONLINE_RESEARCH:".len()..]; + let q = rest.lines().next().map(|l| l.trim()).unwrap_or(rest.trim()); + if !q.is_empty() { + return Some(q.to_string()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_maybe_online_timeout() { + assert!(maybe_online_fallback( + Some("LLM_REQUEST_TIMEOUT: Request: timed out"), + true, + false + )); + } + + #[test] + fn test_maybe_online_schema() { + assert!(maybe_online_fallback( + Some("ERR_SCHEMA_VALIDATION: missing required property"), + true, + false + )); + } + + #[test] + fn test_maybe_online_disabled() { + assert!(!maybe_online_fallback( + Some("ERR_SCHEMA_VALIDATION: x"), + false, + false + )); + } + + #[test] + fn test_maybe_online_already_attempted() { + assert!(!maybe_online_fallback( + Some("ERR_SCHEMA_VALIDATION: x"), + true, + true + )); + } + + #[test] + fn test_extract_needs_online() { + assert_eq!( + extract_needs_online_from_plan(Some("NEEDS_ONLINE_RESEARCH: latest React version"), None), + Some("latest React version".to_string()) + ); + } +} diff --git a/src-tauri/src/online_research/fetch.rs b/src-tauri/src/online_research/fetch.rs new file mode 100644 index 0000000..2a7e674 --- /dev/null +++ b/src-tauri/src/online_research/fetch.rs @@ -0,0 +1,144 @@ +//! SSRF-safe HTTP fetch: запрет localhost, RFC1918, link-local. + +use std::net::IpAddr; +use url::Url; + +/// Проверяет, разрешён ли URL для fetch (запрет SSRF). +fn is_url_allowed(u: &Url) -> bool { + let scheme = u.scheme().to_lowercase(); + if scheme != "http" && scheme != "https" { + return false; + } + let host = match u.host_str() { + Some(h) => h, + None => return false, + }; + let host_lower = host.to_lowercase(); + if host_lower == "localhost" + || host_lower == "127.0.0.1" + || host_lower == "::1" + || host_lower.ends_with(".localhost") + { + return false; + } + let host_clean = host.trim_matches(|c| c == '[' || c == ']'); + if let Ok(ip) = host_clean.parse::() { + if ip.is_loopback() { + return false; + } + if let IpAddr::V4(v4) = ip { + if v4.is_private() { + return false; + } + if v4.is_link_local() { + return false; + } + let octets = v4.octets(); + if octets[0] == 169 && octets[1] == 254 { + return false; + } + } + if let IpAddr::V6(v6) = ip { + if v6.is_loopback() { + return false; + } + let s = v6.to_string(); + if s.starts_with("fe80") || s.starts_with("fe8") || s.starts_with("fe9") { + return false; + } + } + } + true +} + +/// Скачивает URL с ограничениями по размеру и таймауту. SSRF-safe. +pub async fn fetch_url_safe( + url_str: &str, + max_bytes: usize, + timeout_sec: u64, +) -> Result { + let url = Url::parse(url_str).map_err(|e| format!("Invalid URL: {}", e))?; + if !is_url_allowed(&url) { + return Err("URL not allowed (SSRF protection)".into()); + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_sec)) + .redirect(reqwest::redirect::Policy::limited(5)) + .build() + .map_err(|e| format!("HTTP client: {}", e))?; + + let resp = client + .get(url.as_str()) + .send() + .await + .map_err(|e| format!("Request: {}", e))?; + + let final_url = resp.url().clone(); + if !is_url_allowed(&final_url) { + return Err("Redirect to disallowed URL (SSRF protection)".into()); + } + + let status = resp.status(); + if !status.is_success() { + return Err(format!("HTTP {}", status)); + } + + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_lowercase(); + if !content_type.is_empty() + && !content_type.contains("text/html") + && !content_type.contains("text/plain") + && !content_type.contains("application/json") + && !content_type.contains("application/xhtml") + { + return Err(format!("Unsupported content-type: {}", content_type)); + } + + let bytes = resp.bytes().await.map_err(|e| format!("Body: {}", e))?; + if bytes.len() > max_bytes { + return Err(format!("Response too large: {} > {}", bytes.len(), max_bytes)); + } + + let text = String::from_utf8_lossy(&bytes); + Ok(text.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ssrf_block_localhost() { + assert!(!is_url_allowed(&Url::parse("http://localhost/").unwrap())); + assert!(!is_url_allowed(&Url::parse("http://127.0.0.1/").unwrap())); + assert!(!is_url_allowed(&Url::parse("http://[::1]/").unwrap())); + } + + #[test] + fn test_ssrf_block_rfc1918() { + assert!(!is_url_allowed(&Url::parse("http://192.168.1.1/").unwrap())); + assert!(!is_url_allowed(&Url::parse("http://10.0.0.1/").unwrap())); + assert!(!is_url_allowed(&Url::parse("http://172.16.0.1/").unwrap())); + } + + #[test] + fn test_ssrf_block_link_local() { + assert!(!is_url_allowed(&Url::parse("http://169.254.1.1/").unwrap())); + } + + #[test] + fn test_ssrf_allow_public() { + assert!(is_url_allowed(&Url::parse("https://example.com/").unwrap())); + assert!(is_url_allowed(&Url::parse("https://8.8.8.8/").unwrap())); + } + + #[test] + fn test_ssrf_block_file() { + assert!(!is_url_allowed(&Url::parse("file:///etc/passwd").unwrap())); + } +} diff --git a/src-tauri/src/online_research/llm.rs b/src-tauri/src/online_research/llm.rs new file mode 100644 index 0000000..fd9c9d5 --- /dev/null +++ b/src-tauri/src/online_research/llm.rs @@ -0,0 +1,167 @@ +//! LLM summarize with sources (OpenAI Chat Completions + json_schema). + +use jsonschema::JSONSchema; +use super::{OnlineAnswer, OnlineSource, SearchResult}; + +const SYSTEM_PROMPT: &str = r#"Ты отвечаешь на вопрос, используя ТОЛЬКО предоставленные источники (вырезки веб-страниц). +Если в источниках нет ответа — скажи, что данных недостаточно, и предложи уточняющий запрос. +В ответе: +- answer_md: кратко и по делу (markdown) +- sources: перечисли 2–5 наиболее релевантных URL, которые реально использовал +- confidence: 0..1 (0.3 если источники слабые/противоречат) +Не выдумывай факты. Не используй знания вне источников."#; + +/// Суммаризирует страницы через LLM с response_format json_schema. +pub async fn summarize_with_sources( + query: &str, + pages: &[(String, String, String)], + search_results: &[SearchResult], +) -> Result { + let api_url = std::env::var("PAPAYU_LLM_API_URL").map_err(|_| "PAPAYU_LLM_API_URL not set")?; + let api_url = api_url.trim(); + if api_url.is_empty() { + return Err("PAPAYU_LLM_API_URL is empty".into()); + } + let model = std::env::var("PAPAYU_ONLINE_MODEL") + .or_else(|_| std::env::var("PAPAYU_LLM_MODEL")) + .unwrap_or_else(|_| "gpt-4o-mini".to_string()); + let api_key = std::env::var("PAPAYU_LLM_API_KEY").ok(); + + let schema: serde_json::Value = + serde_json::from_str(include_str!("../../config/llm_online_answer_schema.json")) + .map_err(|e| format!("schema: {}", e))?; + + let mut sources_block = String::new(); + for (i, (url, title, text)) in pages.iter().enumerate() { + let truncated = if text.len() > 15_000 { + format!("{}...", &text[..15_000]) + } else { + text.clone() + }; + sources_block.push_str(&format!( + "\n\n--- Источник {}: {} ---\nURL: {}\n\n{}\n", + i + 1, + title, + url, + truncated + )); + } + + let user_content = format!( + "Вопрос: {}\n\nИспользуй только эти источники для ответа:\n{}", + query, sources_block + ); + + let response_format = serde_json::json!({ + "type": "json_schema", + "json_schema": { + "name": "online_answer", + "schema": schema, + "strict": true + } + }); + + let body = serde_json::json!({ + "model": model.trim(), + "messages": [ + { "role": "system", "content": SYSTEM_PROMPT }, + { "role": "user", "content": user_content } + ], + "temperature": 0.2, + "max_tokens": 4096, + "response_format": response_format + }); + + let timeout_sec = std::env::var("PAPAYU_ONLINE_TIMEOUT_SEC") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(20); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_sec)) + .build() + .map_err(|e| format!("HTTP: {}", e))?; + + let mut req = client.post(api_url).json(&body); + if let Some(ref key) = api_key { + if !key.trim().is_empty() { + req = req.header("Authorization", format!("Bearer {}", key.trim())); + } + } + + let resp = req.send().await.map_err(|e| format!("Request: {}", e))?; + let status = resp.status(); + let text = resp.text().await.map_err(|e| format!("Response: {}", e))?; + + if !status.is_success() { + return Err(format!("API {}: {}", status, text)); + } + + let chat: serde_json::Value = + serde_json::from_str(&text).map_err(|e| format!("JSON: {}", e))?; + let content = chat + .get("choices") + .and_then(|c| c.as_array()) + .and_then(|a| a.first()) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .ok_or("No content in response")?; + + let report: serde_json::Value = + serde_json::from_str(content).map_err(|e| format!("Report JSON: {}", e))?; + + let compiled = JSONSchema::options() + .with_draft(jsonschema::Draft::Draft7) + .compile(&schema) + .map_err(|e| format!("Schema: {}", e))?; + if let Err(e) = compiled.validate(&report) { + let msg: Vec = e.map(|ve| format!("{}", ve)).collect(); + return Err(format!("Validation: {}", msg.join("; "))); + } + + let answer_md = report + .get("answer_md") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let confidence = report.get("confidence").and_then(|v| v.as_f64()).unwrap_or(0.0); + let notes = report.get("notes").and_then(|v| v.as_str()).map(|s| s.to_string()); + + let sources: Vec = report + .get("sources") + .and_then(|v| v.as_array()) + .unwrap_or(&vec![]) + .iter() + .filter_map(|s| { + let url = s.get("url")?.as_str()?.to_string(); + let title = s.get("title")?.as_str().unwrap_or("").to_string(); + let published_at = s.get("published_at").and_then(|v| v.as_str()).map(|s| s.to_string()); + let snippet = s.get("snippet").and_then(|v| v.as_str()).map(|s| s.to_string()); + Some(OnlineSource { + url, + title, + published_at, + snippet, + }) + }) + .collect(); + + let mut final_sources = sources; + if final_sources.is_empty() { + for r in search_results.iter().take(5) { + final_sources.push(OnlineSource { + url: r.url.clone(), + title: r.title.clone(), + published_at: None, + snippet: r.snippet.clone(), + }); + } + } + + Ok(OnlineAnswer { + answer_md, + sources: final_sources, + confidence, + notes, + }) +} diff --git a/src-tauri/src/online_research/mod.rs b/src-tauri/src/online_research/mod.rs new file mode 100644 index 0000000..c098a03 --- /dev/null +++ b/src-tauri/src/online_research/mod.rs @@ -0,0 +1,155 @@ +//! Online Research Fallback: Search API + Fetch + LLM. +//! +//! Env: PAPAYU_ONLINE_RESEARCH, PAPAYU_SEARCH_PROVIDER, PAPAYU_TAVILY_API_KEY, +//! PAPAYU_ONLINE_MODEL, PAPAYU_ONLINE_MAX_SOURCES, PAPAYU_ONLINE_MAX_PAGES, +//! PAPAYU_ONLINE_PAGE_MAX_BYTES, PAPAYU_ONLINE_TIMEOUT_SEC. + +mod online_context; +mod extract; +mod fallback; +mod fetch; +mod llm; +mod search; + +#[cfg(test)] +mod online_context_auto_test; + +pub use fallback::{maybe_online_fallback, extract_error_code_prefix}; +pub use self::online_context::{ + build_online_context_block, effective_online_max_chars, online_context_max_chars, + online_context_max_sources, OnlineBlockResult, +}; + +use serde::{Deserialize, Serialize}; + +pub use search::SearchResult; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OnlineAnswer { + pub answer_md: String, + pub sources: Vec, + pub confidence: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OnlineSource { + pub url: String, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub published_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub snippet: Option, +} + +/// Orchestrates: search → fetch → extract → LLM summarize. +pub async fn research_answer(query: &str) -> Result { + if !is_online_research_enabled() { + return Err("Online research disabled (PAPAYU_ONLINE_RESEARCH=1 to enable)".into()); + } + let max_sources = max_sources(); + let max_pages = max_pages(); + let page_max_bytes = page_max_bytes(); + let timeout_sec = timeout_sec(); + + let search_results = search::tavily_search(query, max_sources).await?; + let mut pages: Vec<(String, String, String)> = vec![]; + let mut fetch_failures = 0usize; + for r in search_results.iter().take(max_pages) { + match fetch::fetch_url_safe(&r.url, page_max_bytes, timeout_sec).await { + Ok(body) => { + let text = extract::extract_text(&body); + if !text.trim().is_empty() { + pages.push((r.url.clone(), r.title.clone(), text)); + } + } + Err(e) => { + fetch_failures += 1; + eprintln!("[online_research] fetch {} failed: {}", r.url, e); + } + } + } + + let online_model = std::env::var("PAPAYU_ONLINE_MODEL") + .or_else(|_| std::env::var("PAPAYU_LLM_MODEL")) + .unwrap_or_else(|_| "gpt-4o-mini".to_string()); + eprintln!( + "[trace] ONLINE_RESEARCH query_len={} sources_count={} pages_fetched={} fetch_failures={} model={}", + query.len(), + search_results.len(), + pages.len(), + fetch_failures, + online_model.trim() + ); + + if pages.is_empty() { + return Ok(OnlineAnswer { + answer_md: format!( + "Не удалось загрузить источники для запроса «{}». Попробуйте уточнить запрос или проверить доступность поиска.", + query + ), + sources: search_results + .iter() + .take(5) + .map(|r| OnlineSource { + url: r.url.clone(), + title: r.title.clone(), + published_at: None, + snippet: r.snippet.clone(), + }) + .collect(), + confidence: 0.0, + notes: Some("No pages fetched".into()), + }); + } + + llm::summarize_with_sources(query, &pages, &search_results).await +} + +pub fn is_online_research_enabled() -> bool { + std::env::var("PAPAYU_ONLINE_RESEARCH") + .ok() + .map(|s| matches!(s.trim().to_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false) +} + +/// Проверяет, включен ли auto-use as context для online research. +pub fn is_online_auto_use_as_context() -> bool { + std::env::var("PAPAYU_ONLINE_AUTO_USE_AS_CONTEXT") + .ok() + .map(|s| matches!(s.trim().to_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false) +} + +fn max_sources() -> usize { + std::env::var("PAPAYU_ONLINE_MAX_SOURCES") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(5) + .clamp(1, 20) +} + +fn max_pages() -> usize { + std::env::var("PAPAYU_ONLINE_MAX_PAGES") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(4) + .clamp(1, 10) +} + +fn page_max_bytes() -> usize { + std::env::var("PAPAYU_ONLINE_PAGE_MAX_BYTES") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(200_000) + .clamp(10_000, 500_000) +} + +fn timeout_sec() -> u64 { + std::env::var("PAPAYU_ONLINE_TIMEOUT_SEC") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(20) + .clamp(5, 60) +} diff --git a/src-tauri/src/online_research/online_context.rs b/src-tauri/src/online_research/online_context.rs new file mode 100644 index 0000000..56a245f --- /dev/null +++ b/src-tauri/src/online_research/online_context.rs @@ -0,0 +1,160 @@ +//! Online context: truncation, sanitization, block building. + +/// Максимум символов для online summary (PAPAYU_ONLINE_CONTEXT_MAX_CHARS). +pub fn online_context_max_chars() -> usize { + std::env::var("PAPAYU_ONLINE_CONTEXT_MAX_CHARS") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(8000) + .clamp(256, 32_000) +} + +/// Максимум источников (PAPAYU_ONLINE_CONTEXT_MAX_SOURCES). +pub fn online_context_max_sources() -> usize { + std::env::var("PAPAYU_ONLINE_CONTEXT_MAX_SOURCES") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(10) + .clamp(1, 20) +} + +/// Урезает и санитизирует online markdown: по char boundary, без NUL/control, \r\n -> \n. +pub fn truncate_online_context(md: &str, max_chars: usize) -> String { + let sanitized: String = md + .chars() + .filter(|c| !c.is_control() || *c == '\n' || *c == '\t') + .collect(); + let normalized = sanitized.replace("\r\n", "\n").replace('\r', "\n"); + if normalized.chars().count() <= max_chars { + normalized + } else { + normalized.chars().take(max_chars).collect::() + "..." + } +} + +/// Результат сборки online-блока: (block, was_truncated, dropped). +#[derive(Clone, Debug)] +pub struct OnlineBlockResult { + pub block: String, + pub was_truncated: bool, + pub dropped: bool, + pub chars_used: usize, + pub sources_count: usize, +} + +/// Собирает блок ONLINE_RESEARCH_SUMMARY + ONLINE_SOURCES для вставки в prompt. +/// sources — список URL (обрезается по max_sources). +pub fn build_online_context_block(md: &str, sources: &[String], max_chars: usize, max_sources: usize) -> OnlineBlockResult { + let truncated = truncate_online_context(md, max_chars); + let was_truncated = md.chars().count() > max_chars; + + if truncated.trim().len() < 64 { + return OnlineBlockResult { + block: String::new(), + was_truncated: false, + dropped: true, + chars_used: 0, + sources_count: 0, + }; + } + + let sources_trimmed: Vec<&str> = sources.iter().map(|s| s.as_str()).take(max_sources).collect(); + let mut block = String::new(); + block.push_str("\n\nONLINE_RESEARCH_SUMMARY:\n"); + block.push_str(&truncated); + block.push_str("\n\nONLINE_SOURCES:\n"); + for url in &sources_trimmed { + block.push_str("- "); + block.push_str(url); + block.push('\n'); + } + + let chars_used = block.chars().count(); + OnlineBlockResult { + block, + was_truncated, + dropped: false, + chars_used, + sources_count: sources_trimmed.len(), + } +} + +/// Вычисляет допустимый max_chars для online с учётом общего бюджета. +/// rest_context_chars — размер base + prompt_body + auto без online. +/// priority0_reserved — минимальный резерв для FILE (4096). +/// Если после вычета online осталось бы < 512 chars — вернёт 0 (drop). +pub fn effective_online_max_chars( + rest_context_chars: usize, + max_total: usize, + priority0_reserved: usize, +) -> usize { + let available = max_total.saturating_sub(rest_context_chars).saturating_sub(priority0_reserved); + if available < 512 { + 0 + } else { + available + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_online_context_limits() { + let md = "a".repeat(10_000); + let t = truncate_online_context(&md, 1000); + assert!(t.len() <= 1004); // 1000 + "..." + assert!(t.ends_with("...")); + } + + #[test] + fn test_truncate_removes_control() { + let md = "hello\x00world\nok"; + let t = truncate_online_context(md, 100); + assert!(!t.contains('\x00')); + assert!(t.contains("hello")); + } + + #[test] + fn test_truncate_normalizes_crlf() { + let md = "a\r\nb\r\nc"; + let t = truncate_online_context(md, 100); + assert!(!t.contains("\r")); + } + + #[test] + fn test_build_block_dropped_when_short() { + let r = build_online_context_block("x", &[], 8000, 10); + assert!(r.block.is_empty()); + assert!(r.dropped); + } + + #[test] + fn test_build_block_contains_summary() { + let md = "This is a longer summary with enough content to pass the 64 char minimum."; + let r = build_online_context_block(md, &["https://example.com".into()], 8000, 10); + assert!(!r.dropped); + assert!(r.block.contains("ONLINE_RESEARCH_SUMMARY:")); + assert!(r.block.contains("ONLINE_SOURCES:")); + assert!(r.block.contains("https://example.com")); + } + + #[test] + fn test_effective_online_max_chars_drops_when_budget_small() { + let rest = 119_000; + let max_total = 120_000; + let reserved = 4096; + let effective = effective_online_max_chars(rest, max_total, reserved); + assert_eq!(effective, 0); + } + + #[test] + fn test_effective_online_max_chars_returns_available() { + let rest = 50_000; + let max_total = 120_000; + let reserved = 4096; + let effective = effective_online_max_chars(rest, max_total, reserved); + assert!(effective >= 65_000); + } +} diff --git a/src-tauri/src/online_research/online_context_auto_test.rs b/src-tauri/src/online_research/online_context_auto_test.rs new file mode 100644 index 0000000..6f51d0f --- /dev/null +++ b/src-tauri/src/online_research/online_context_auto_test.rs @@ -0,0 +1,37 @@ +//! Tests for auto-use online context flow. + +#[cfg(test)] +mod tests { + use crate::online_research; + + #[test] + fn test_is_online_auto_use_disabled_by_default() { + std::env::remove_var("PAPAYU_ONLINE_AUTO_USE_AS_CONTEXT"); + assert!(!online_research::is_online_auto_use_as_context()); + } + + #[test] + fn test_is_online_auto_use_enabled_when_set() { + std::env::set_var("PAPAYU_ONLINE_AUTO_USE_AS_CONTEXT", "1"); + assert!(online_research::is_online_auto_use_as_context()); + std::env::remove_var("PAPAYU_ONLINE_AUTO_USE_AS_CONTEXT"); + } + + #[test] + fn test_extract_error_code_prefix_timeout() { + let msg = "LLM_REQUEST_TIMEOUT: request timed out"; + assert_eq!(online_research::extract_error_code_prefix(msg), "LLM_REQUEST_TIMEOUT"); + } + + #[test] + fn test_extract_error_code_prefix_schema() { + let msg = "ERR_SCHEMA_VALIDATION: missing required property"; + assert_eq!(online_research::extract_error_code_prefix(msg), "ERR_SCHEMA_VALIDATION"); + } + + #[test] + fn test_extract_error_code_prefix_empty_when_no_prefix() { + let msg = "Some generic error message"; + assert_eq!(online_research::extract_error_code_prefix(msg), ""); + } +} diff --git a/src-tauri/src/online_research/search.rs b/src-tauri/src/online_research/search.rs new file mode 100644 index 0000000..5a25ed3 --- /dev/null +++ b/src-tauri/src/online_research/search.rs @@ -0,0 +1,68 @@ +//! Search provider: Tavily API. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResult { + pub title: String, + pub url: String, + pub snippet: Option, +} + +/// Tavily Search API: POST https://api.tavily.com/search +pub async fn tavily_search(query: &str, max_results: usize) -> Result, String> { + let api_key = std::env::var("PAPAYU_TAVILY_API_KEY") + .map_err(|_| "PAPAYU_TAVILY_API_KEY not set")?; + let api_key = api_key.trim(); + if api_key.is_empty() { + return Err("PAPAYU_TAVILY_API_KEY is empty".into()); + } + + let body = serde_json::json!({ + "query": query, + "max_results": max_results, + "include_answer": false, + "include_raw_content": false, + }); + + let timeout = std::time::Duration::from_secs(15); + let client = reqwest::Client::builder() + .timeout(timeout) + .build() + .map_err(|e| format!("HTTP client: {}", e))?; + + let resp = client + .post("https://api.tavily.com/search") + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_key)) + .json(&body) + .send() + .await + .map_err(|e| format!("Tavily request: {}", e))?; + + let status = resp.status(); + let text = resp.text().await.map_err(|e| format!("Response: {}", e))?; + + if !status.is_success() { + return Err(format!("Tavily API {}: {}", status, text)); + } + + let val: serde_json::Value = + serde_json::from_str(&text).map_err(|e| format!("Tavily JSON: {}", e))?; + let results = val + .get("results") + .and_then(|r| r.as_array()) + .ok_or_else(|| "Tavily: no results array".to_string())?; + + let out: Vec = results + .iter() + .filter_map(|r| { + let url = r.get("url")?.as_str()?.to_string(); + let title = r.get("title")?.as_str().unwrap_or("").to_string(); + let snippet = r.get("content").and_then(|v| v.as_str()).map(|s| s.to_string()); + Some(SearchResult { title, url, snippet }) + }) + .collect(); + + Ok(out) +} diff --git a/src-tauri/src/patch.rs b/src-tauri/src/patch.rs new file mode 100644 index 0000000..1907197 --- /dev/null +++ b/src-tauri/src/patch.rs @@ -0,0 +1,97 @@ +//! PATCH_FILE engine: sha256, unified diff validation, apply. + +use sha2::{Digest, Sha256}; + +/// SHA256 hex (lowercase) от bytes. +pub fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex::encode(hasher.finalize()) +} + +/// Проверка: строка — валидный sha256 hex (64 символа, 0-9a-f). +pub fn is_valid_sha256_hex(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +/// Минимальная проверка unified diff: хотя бы один hunk, желательно ---/+++. +pub fn looks_like_unified_diff(patch: &str) -> bool { + let mut has_hunk = false; + let mut has_minus_file = false; + let mut has_plus_file = false; + + for line in patch.lines() { + if line.starts_with("@@") { + has_hunk = true; + } + if line.starts_with("--- ") { + has_minus_file = true; + } + if line.starts_with("+++ ") { + has_plus_file = true; + } + } + + has_hunk && ((has_minus_file && has_plus_file) || patch.len() > 40) +} + +/// Применяет unified diff к тексту. Возвращает Err("parse_failed") или Err("apply_failed"). +pub fn apply_unified_diff_to_text(old_text: &str, patch_text: &str) -> Result { + use diffy::{apply, Patch}; + let patch = Patch::from_str(patch_text).map_err(|_| "parse_failed")?; + apply(old_text, &patch).map_err(|_| "apply_failed") +} + +/// PAPAYU_NORMALIZE_EOL=lf — \r\n→\n, trailing newline. +pub fn normalize_lf_with_trailing_newline(s: &str) -> String { + let mut out = s.replace("\r\n", "\n").replace('\r', "\n"); + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use diffy::create_patch; + + #[test] + fn test_sha256_hex() { + let s = "hello"; + let h = sha256_hex(s.as_bytes()); + assert_eq!(h.len(), 64); + assert!(h.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_is_valid_sha256_hex() { + assert!(is_valid_sha256_hex("a".repeat(64).as_str())); + assert!(is_valid_sha256_hex(&"0".repeat(64))); + assert!(!is_valid_sha256_hex("abc")); + assert!(!is_valid_sha256_hex(&"g".repeat(64))); + } + + #[test] + fn test_looks_like_unified_diff() { + let patch = r#"--- a/foo ++++ b/foo +@@ -1,3 +1,4 @@ + line1 ++line2 + line3"#; + assert!(looks_like_unified_diff(patch)); + assert!(!looks_like_unified_diff("not a diff")); + } + + #[test] + fn test_apply_unified_diff() { + // Используем create_patch для гарантированного формата diffy + let old = "line1\nline3\n"; + let new_expected = "line1\nline2\nline3\n"; + let patch = create_patch(old, new_expected); + let patch_str = format!("{}", patch); + let applied = apply_unified_diff_to_text(old, &patch_str).unwrap(); + assert_eq!(applied, new_expected); + } +} diff --git a/src-tauri/src/protocol.rs b/src-tauri/src/protocol.rs new file mode 100644 index 0000000..b884044 --- /dev/null +++ b/src-tauri/src/protocol.rs @@ -0,0 +1,88 @@ +//! Protocol versioning: v1/v2 default, fallback, env vars. + +use std::cell::RefCell; + +/// Коды ошибок, при которых v2 fallback на v1 (только для APPLY). +pub const V2_FALLBACK_ERROR_CODES: &[&str] = &[ + "ERR_PATCH_APPLY_FAILED", + "ERR_NON_UTF8_FILE", + "ERR_V2_UPDATE_EXISTING_FORBIDDEN", +]; + +/// Ошибки, для которых сначала repair v2, потом fallback. +pub const V2_REPAIR_FIRST_ERROR_CODES: &[&str] = &[ + "ERR_PATCH_APPLY_FAILED", + "ERR_V2_UPDATE_EXISTING_FORBIDDEN", +]; + +/// Ошибка, для которой fallback сразу (repair бессмысленен). +pub const V2_IMMEDIATE_FALLBACK_ERROR_CODES: &[&str] = &["ERR_NON_UTF8_FILE"]; + +thread_local! { + static EFFECTIVE_PROTOCOL: RefCell> = RefCell::new(None); +} + +/// Читает PAPAYU_PROTOCOL_DEFAULT, затем PAPAYU_PROTOCOL_VERSION. Default 2. +pub fn protocol_default() -> u32 { + std::env::var("PAPAYU_PROTOCOL_DEFAULT") + .or_else(|_| std::env::var("PAPAYU_PROTOCOL_VERSION")) + .ok() + .and_then(|s| s.trim().parse().ok()) + .filter(|v| *v == 1 || *v == 2) + .unwrap_or(2) +} + +/// Читает PAPAYU_PROTOCOL_FALLBACK_TO_V1. Default 1 (включён). +pub fn protocol_fallback_enabled() -> bool { + std::env::var("PAPAYU_PROTOCOL_FALLBACK_TO_V1") + .ok() + .map(|s| matches!(s.trim(), "1" | "true" | "yes")) + .unwrap_or(true) +} + +/// Эффективная версия: thread-local override → arg override → default. +pub fn protocol_version(override_version: Option) -> u32 { + if let Some(v) = override_version.filter(|v| *v == 1 || *v == 2) { + return v; + } + EFFECTIVE_PROTOCOL.with(|c| { + if let Some(v) = *c.borrow() { + return v; + } + protocol_default() + }) +} + +/// Устанавливает версию протокола для текущего потока. Очищается при drop. +pub fn set_protocol_version(version: u32) -> ProtocolVersionGuard { + EFFECTIVE_PROTOCOL.with(|c| { + *c.borrow_mut() = Some(version); + }); + ProtocolVersionGuard +} + +pub struct ProtocolVersionGuard; + +impl Drop for ProtocolVersionGuard { + fn drop(&mut self) { + EFFECTIVE_PROTOCOL.with(|c| { + *c.borrow_mut() = None; + }); + } +} + +/// Проверяет, нужен ли fallback на v1 при данной ошибке. +/// repair_attempt: 0 = первый retry, 1 = repair уже пробовали. +/// Для ERR_NON_UTF8_FILE — fallback сразу. Для PATCH_APPLY_FAILED и UPDATE_EXISTING_FORBIDDEN — repair сначала. +pub fn should_fallback_to_v1(error_code: &str, repair_attempt: u32) -> bool { + if !V2_FALLBACK_ERROR_CODES.contains(&error_code) { + return false; + } + if V2_IMMEDIATE_FALLBACK_ERROR_CODES.contains(&error_code) { + return true; + } + if V2_REPAIR_FIRST_ERROR_CODES.contains(&error_code) && repair_attempt >= 1 { + return true; + } + false +} diff --git a/src-tauri/src/tx/limits.rs b/src-tauri/src/tx/limits.rs index d7e9720..ae64ebd 100644 --- a/src-tauri/src/tx/limits.rs +++ b/src-tauri/src/tx/limits.rs @@ -74,6 +74,10 @@ pub fn preflight_actions(root: &Path, actions: &[Action]) -> Result<(), (String, } total_bytes += len; } + ActionKind::PatchFile => { + files_touched += 1; + total_bytes += a.patch.as_deref().map(|s| s.len() as u64).unwrap_or(0); + } ActionKind::CreateDir => { dirs_created += 1; } diff --git a/src-tauri/src/tx/mod.rs b/src-tauri/src/tx/mod.rs index 7bed703..69c43c8 100644 --- a/src-tauri/src/tx/mod.rs +++ b/src-tauri/src/tx/mod.rs @@ -210,11 +210,25 @@ pub fn normalize_content_for_write(content: &str, _path: &Path) -> String { s } +fn protocol_version(override_version: Option) -> u32 { + crate::protocol::protocol_version(override_version) +} + /// Apply a single action to disk (v2.3.3: for atomic apply + rollback on first failure). -pub fn apply_one_action(root: &Path, action: &Action) -> Result<(), String> { +pub fn apply_one_action(root: &Path, action: &Action, protocol_override: Option) -> Result<(), String> { let full = safe_join(root, &action.path)?; match action.kind { ActionKind::CreateFile | ActionKind::UpdateFile => { + // v2: UPDATE_FILE запрещён для существующих файлов + if action.kind == ActionKind::UpdateFile + && protocol_version(protocol_override) == 2 + && full.is_file() + { + return Err(format!( + "ERR_V2_UPDATE_EXISTING_FORBIDDEN: UPDATE_FILE path '{}' существует. В v2 используй PATCH_FILE.", + action.path + )); + } if let Some(p) = full.parent() { fs::create_dir_all(p).map_err(|e| e.to_string())?; } @@ -222,6 +236,9 @@ pub fn apply_one_action(root: &Path, action: &Action) -> Result<(), String> { let normalized = normalize_content_for_write(content, &full); fs::write(&full, normalized).map_err(|e| e.to_string())?; } + ActionKind::PatchFile => { + apply_patch_file_impl(root, &action.path, action)?; + } ActionKind::CreateDir => { fs::create_dir_all(&full).map_err(|e| e.to_string())?; } @@ -239,14 +256,59 @@ pub fn apply_one_action(root: &Path, action: &Action) -> Result<(), String> { Ok(()) } -/// Порядок применения: CREATE_DIR → CREATE_FILE/UPDATE_FILE → DELETE_FILE → DELETE_DIR. +fn apply_patch_file_impl(root: &Path, path: &str, action: &Action) -> Result<(), String> { + use crate::patch::{ + apply_unified_diff_to_text, is_valid_sha256_hex, looks_like_unified_diff, + normalize_lf_with_trailing_newline, sha256_hex, + }; + let patch_text = action.patch.as_deref().unwrap_or(""); + let base_sha256 = action.base_sha256.as_deref().unwrap_or(""); + if !looks_like_unified_diff(patch_text) { + return Err("ERR_PATCH_NOT_UNIFIED: patch is not unified diff".into()); + } + if !is_valid_sha256_hex(base_sha256) { + return Err("ERR_BASE_SHA256_INVALID: base_sha256 invalid (64 hex chars)".into()); + } + let full = safe_join(root, path)?; + if !full.is_file() { + return Err(format!( + "ERR_BASE_MISMATCH: file not found for PATCH_FILE '{}'", + path + )); + } + let old_bytes = fs::read(&full).map_err(|e| format!("ERR_IO: {}", e))?; + let old_sha = sha256_hex(&old_bytes); + if old_sha != base_sha256 { + return Err(format!( + "ERR_BASE_MISMATCH: base mismatch: have {}, want {}", + old_sha, base_sha256 + )); + } + let old_text = String::from_utf8(old_bytes) + .map_err(|_| String::from("ERR_NON_UTF8_FILE: PATCH_FILE requires utf-8"))?; + let mut new_text = apply_unified_diff_to_text(&old_text, patch_text) + .map_err(|_| String::from("ERR_PATCH_APPLY_FAILED: could not apply patch"))?; + let normalize_eol = std::env::var("PAPAYU_NORMALIZE_EOL") + .map(|s| s.trim().to_lowercase() == "lf") + .unwrap_or(false); + if normalize_eol { + new_text = normalize_lf_with_trailing_newline(&new_text); + } + if let Some(p) = full.parent() { + fs::create_dir_all(p).map_err(|e| e.to_string())?; + } + fs::write(&full, new_text).map_err(|e| e.to_string()) +} + +/// Порядок применения: CREATE_DIR → CREATE_FILE/UPDATE_FILE → PATCH_FILE → DELETE_FILE → DELETE_DIR. pub fn sort_actions_for_apply(actions: &mut [Action]) { fn order(k: &ActionKind) -> u8 { match k { ActionKind::CreateDir => 0, ActionKind::CreateFile | ActionKind::UpdateFile => 1, - ActionKind::DeleteFile => 2, - ActionKind::DeleteDir => 3, + ActionKind::PatchFile => 2, + ActionKind::DeleteFile => 3, + ActionKind::DeleteDir => 4, } } actions.sort_by_key(|a| (order(&a.kind), a.path.clone())); @@ -258,7 +320,7 @@ pub fn apply_actions_to_disk(root: &Path, actions: &[Action]) -> Result<(), Stri let mut sorted: Vec = actions.to_vec(); sort_actions_for_apply(&mut sorted); for a in &sorted { - apply_one_action(root, a)?; + apply_one_action(root, a, None)?; } Ok(()) } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 5a275cd..129685d 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -6,6 +6,12 @@ pub struct Action { pub path: String, #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, + /// v2 PATCH_FILE: unified diff + #[serde(skip_serializing_if = "Option::is_none")] + pub patch: Option, + /// v2 PATCH_FILE: sha256 hex текущей версии файла + #[serde(skip_serializing_if = "Option::is_none")] + pub base_sha256: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -14,6 +20,7 @@ pub enum ActionKind { CreateFile, CreateDir, UpdateFile, + PatchFile, DeleteFile, DeleteDir, } @@ -158,6 +165,11 @@ pub struct DiffItem { /// v2.4.2: BLOCKED — защищённый/не-текстовый файл #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, + /// v2: bytes до/после для PATCH_FILE (UX) + #[serde(skip_serializing_if = "Option::is_none")] + pub bytes_before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bytes_after: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -314,14 +326,29 @@ pub struct AgentPlan { /// Собранный контекст для передачи в Apply вместе с plan_json. #[serde(skip_serializing_if = "Option::is_none")] pub plan_context: Option, + /// Версия протокола, использованная при генерации (для v1 fallback apply). + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol_version_used: Option, + /// При ok=false и триггере online fallback: UI вызывает researchAnswer(query). + #[serde(skip_serializing_if = "Option::is_none")] + pub online_fallback_suggested: Option, + /// true — online_context_md был принят и вставлен в prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub online_context_used: Option, } /// v3.1: опции применения (auto_check). v2.4.2: user_confirmed для apply_actions_tx. +/// protocol_version_override: при v1 fallback после v2 APPLY failure. +/// fallback_attempted: true — применяем v1 fallback; при ошибке не повторять fallback. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApplyOptions { pub auto_check: bool, #[serde(default)] pub user_confirmed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol_version_override: Option, + #[serde(default)] + pub fallback_attempted: bool, } /// v3.1: результат этапа проверки (verify / build / smoke) @@ -345,6 +372,8 @@ pub struct ApplyTxResult { pub error: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol_fallback_stage: Option, } /// v3.2: результат генерации действий из отчёта (generate_actions_from_report) diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 70114ad..6a27030 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -32,6 +32,8 @@ export interface RunBatchPayload { export interface ApplyActionsTxOptions { auto_check: boolean; user_confirmed: boolean; + protocol_version_override?: number | null; + fallback_attempted?: boolean; } export interface ProjectItem { @@ -152,6 +154,9 @@ export interface AgentPlanResult { error_code?: string; plan_json?: string; plan_context?: string; + protocol_version_used?: number | null; + online_fallback_suggested?: string | null; + online_context_used?: boolean | null; } export async function proposeActions( @@ -161,7 +166,16 @@ export async function proposeActions( designStyle?: string | null, trendsContext?: string | null, lastPlanJson?: string | null, - lastContext?: string | null + lastContext?: string | null, + applyErrorCode?: string | null, + applyErrorValidatedJson?: string | null, + applyRepairAttempt?: number | null, + applyErrorStage?: string | null, + onlineFallbackAttempted?: boolean | null, + onlineContextMd?: string | null, + onlineContextSources?: string[] | null, + onlineFallbackExecuted?: boolean | null, + onlineFallbackReason?: string | null ): Promise { return invoke("propose_actions", { path, @@ -171,6 +185,15 @@ export async function proposeActions( trendsContext: trendsContext ?? null, lastPlanJson: lastPlanJson ?? null, lastContext: lastContext ?? null, + applyErrorCode: applyErrorCode ?? null, + applyErrorValidatedJson: applyErrorValidatedJson ?? null, + applyRepairAttempt: applyRepairAttempt ?? null, + applyErrorStage: applyErrorStage ?? null, + onlineFallbackAttempted: onlineFallbackAttempted ?? null, + onlineContextMd: onlineContextMd ?? null, + onlineContextSources: onlineContextSources ?? null, + onlineFallbackExecuted: onlineFallbackExecuted ?? null, + onlineFallbackReason: onlineFallbackReason ?? null, }); } @@ -219,3 +242,26 @@ export async function exportSettings(): Promise { export async function importSettings(json: string, mode?: "replace" | "merge"): Promise { return invoke("import_settings", { json, mode: mode ?? "merge" }); } + +/** Еженедельный отчёт: агрегация трасс и генерация через LLM */ +export async function analyzeWeeklyReports( + projectPath: string, + from?: string | null, + to?: string | null +): Promise { + return invoke("analyze_weekly_reports_cmd", { + projectPath, + from: from ?? null, + to: to ?? null, + }); +} + +/** Сохранить отчёт в docs/reports/weekly_YYYY-MM-DD.md */ +export async function saveReport(projectPath: string, reportMd: string, date?: string | null): Promise { + return invoke("save_report_cmd", { projectPath, reportMd, date: date ?? null }); +} + +/** Online research: поиск Tavily + fetch + LLM summarize. Требует PAPAYU_ONLINE_RESEARCH=1, PAPAYU_TAVILY_API_KEY. */ +export async function researchAnswer(query: string): Promise { + return invoke("research_answer_cmd", { query }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 2dfc132..8da7e67 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -73,6 +73,10 @@ export interface AgentPlan { plan_json?: string; /** Собранный контекст для Apply */ plan_context?: string; + /** При ok=false и триггере online fallback: UI вызывает researchAnswer(query) */ + online_fallback_suggested?: string | null; + /** true — online_context_md был принят и вставлен в prompt */ + online_context_used?: boolean | null; } /** Тренды и рекомендации (мониторинг не реже раз в месяц) */ @@ -98,6 +102,7 @@ export interface ApplyTxResult { checks: { stage: string; ok: boolean; output: string }[]; error?: string; error_code?: string; + protocol_fallback_stage?: string | null; } /** v3.2: результат generate_actions_from_report */ @@ -205,3 +210,28 @@ export interface Session { updated_at: string; events: SessionEvent[]; } + +/** Источник online research */ +export interface OnlineSource { + url: string; + title: string; + published_at?: string; + snippet?: string; +} + +/** Результат online research */ +export interface OnlineAnswer { + answer_md: string; + sources: OnlineSource[]; + confidence: number; + notes?: string; +} + +/** Результат еженедельного отчёта */ +export interface WeeklyReportResult { + ok: boolean; + error?: string; + stats_bundle?: unknown; + llm_report?: unknown; + report_md?: string; +} diff --git a/src/pages/Tasks.tsx b/src/pages/Tasks.tsx index c03bdf8..4073ff0 100644 --- a/src/pages/Tasks.tsx +++ b/src/pages/Tasks.tsx @@ -20,6 +20,9 @@ import { fetchTrendsRecommendations, exportSettings, importSettings, + analyzeWeeklyReports, + saveReport, + researchAnswer, } from "@/lib/tauri"; import { AgenticResult } from "@/pages/tasks/AgenticResult"; import { useUndoRedo } from "@/pages/tasks/useUndoRedo"; @@ -30,6 +33,7 @@ import type { AnalyzeReport, ChatMessage, DiffItem, + OnlineSource, ProjectProfile, ApplyTxResult, AgenticRunRequest, @@ -92,10 +96,23 @@ export default function Tasks() { const applyingRef = useRef(false); const [requestHistory, setRequestHistory] = useState<{ id: string; title: string; messages: ChatMessage[]; lastPath: string | null; lastReport: AnalyzeReport | null }[]>([]); const [trendsModalOpen, setTrendsModalOpen] = useState(false); + const [weeklyReportModalOpen, setWeeklyReportModalOpen] = useState(false); + const [weeklyReport, setWeeklyReport] = useState<{ reportMd: string; projectPath: string } | null>(null); + const [weeklyReportLoading, setWeeklyReportLoading] = useState(false); const [selectedRecommendation, setSelectedRecommendation] = useState(null); const [attachmentMenuOpen, setAttachmentMenuOpen] = useState(false); const [lastPlanJson, setLastPlanJson] = useState(null); const [lastPlanContext, setLastPlanContext] = useState(null); + const lastGoalWithOnlineFallbackRef = useRef(null); + const [lastOnlineAnswer, setLastOnlineAnswer] = useState<{ answer_md: string; sources: OnlineSource[]; confidence: number } | null>(null); + const [onlineContextPending, setOnlineContextPending] = useState<{ md: string; sources: string[] } | null>(null); + const [onlineAutoUseAsContext, setOnlineAutoUseAsContext] = useState(() => { + try { + const stored = localStorage.getItem("papa_yu_online_auto_use_as_context"); + if (stored !== null) return stored === "true"; + } catch (_) {} + return false; + }); const { undoAvailable, redoAvailable, refreshUndoRedo, handleUndo, handleRedo, setUndoAvailable } = useUndoRedo(lastPath, { setMessages, @@ -116,6 +133,12 @@ export default function Tasks() { })(); }, []); + useEffect(() => { + try { + localStorage.setItem("papa_yu_online_auto_use_as_context", String(onlineAutoUseAsContext)); + } catch (_) {} + }, [onlineAutoUseAsContext]); + useEffect(() => { if (!lastPath) { setSessions([]); @@ -534,7 +557,75 @@ export default function Tasks() { await refreshUndoRedo(); } else { const code = res.error_code || ""; - if (code === "CONFIRM_REQUIRED") { + const isBaseShaError = code === "ERR_BASE_MISMATCH" || code === "ERR_BASE_SHA256_INVALID"; + const isV2FallbackError = ["ERR_PATCH_APPLY_FAILED", "ERR_NON_UTF8_FILE", "ERR_V2_UPDATE_EXISTING_FORBIDDEN"].includes(code); + const repairFirstErrors = ["ERR_PATCH_APPLY_FAILED", "ERR_V2_UPDATE_EXISTING_FORBIDDEN"]; + const canRetry = (isBaseShaError || isV2FallbackError) && lastPlanJson && lastPlanContext; + if (canRetry) { + let repairAttempt = 0; + let lastPlanJsonRetry = lastPlanJson; + let lastPlanContextRetry = lastPlanContext; + let lastErrorCode = code; + let retryRes: ApplyTxResult | null = null; + const maxRetries = repairFirstErrors.includes(code) ? 2 : 1; + for (let attempt = 0; attempt < maxRetries; attempt++) { + const isFallback = repairFirstErrors.includes(lastErrorCode) && repairAttempt >= 1; + setApplyProgressLog((prev) => [ + ...prev, + isFallback ? "Retry v1 fallback…" : isBaseShaError ? "Retry с repair (base_sha256)…" : "Retry repair…", + ]); + try { + const plan = await proposeActions( + path, + lastReportJson ?? "{}", + "ok", + designStyle.trim() || undefined, + undefined, + lastPlanJsonRetry, + lastPlanContextRetry, + lastErrorCode, + lastPlanJsonRetry, + repairAttempt, + "apply", + undefined, + undefined, + undefined, + undefined + ); + if (!plan.ok || plan.actions.length === 0) break; + retryRes = await apiApplyActionsTx(path, plan.actions, { + auto_check: autoCheck, + user_confirmed: true, + protocol_version_override: plan.protocol_version_used ?? undefined, + fallback_attempted: plan.protocol_version_used === 1, + }); + setApplyResult(retryRes); + setApplyProgressLog((prev) => [...prev, retryRes!.ok ? "Готово." : (retryRes!.error || "Ошибка")]); + if (retryRes.ok) { + setMessages((m) => [ + ...m, + { role: "system", text: plan.protocol_version_used === 1 ? "Изменения применены (v1 fallback)." : "Изменения применены (repair). Проверки пройдены." }, + ]); + setPendingPreview(null); + setPendingActions(null); + setPendingActionIdx({}); + await refreshUndoRedo(); + break; + } + lastErrorCode = retryRes.error_code || lastErrorCode; + repairAttempt = 1; + if (plan.protocol_version_used === 1) break; + } catch (e) { + setApplyProgressLog((prev) => [...prev, `Retry failed: ${String(e)}`]); + break; + } + } + if (retryRes && !retryRes.ok) { + setMessages((m) => [...m, { role: "system", text: retryRes.error || retryRes.error_code || "Ошибка применения." }]); + } else if (!retryRes) { + setMessages((m) => [...m, { role: "system", text: res.error || res.error_code || "Ошибка применения." }]); + } + } else if (code === "CONFIRM_REQUIRED") { setMessages((m) => [...m, { role: "system", text: "Подтверждение обязательно перед применением." }]); } else if (code === "AUTO_CHECK_FAILED_ROLLED_BACK") { setMessages((m) => [...m, { role: "system", text: "Изменения привели к ошибкам, откат выполнен." }]); @@ -772,6 +863,11 @@ export default function Tasks() { : undefined; } catch (_) {} } + const pending = onlineContextPending; + if (pending) { + setOnlineContextPending(null); + setLastOnlineAnswer(null); + } const plan = await proposeActions( pathToUse, reportToUse, @@ -779,10 +875,94 @@ export default function Tasks() { designStyle.trim() || undefined, trendsContext, lastPlanJson ?? undefined, - lastPlanContext ?? undefined + lastPlanContext ?? undefined, + undefined, + undefined, + undefined, + undefined, + lastGoalWithOnlineFallbackRef.current === goal, + pending?.md ?? undefined, + pending?.sources ?? undefined, + !!pending ); if (!plan.ok) { - setMessages((m) => [...m, { role: "assistant", text: plan.error ?? "Ошибка формирования плана" }]); + if (plan.online_fallback_suggested) { + const isAutoUse = onlineAutoUseAsContext; + const alreadyAttempted = lastGoalWithOnlineFallbackRef.current === goal; + if (isAutoUse && !alreadyAttempted) { + lastGoalWithOnlineFallbackRef.current = goal; + setMessages((m) => [...m, { role: "assistant", text: plan.error ?? "Ошибка формирования плана" }]); + setMessages((m) => [...m, { role: "system", text: "Онлайн-поиск (auto)…" }]); + try { + const online = await researchAnswer(plan.online_fallback_suggested); + setLastOnlineAnswer({ answer_md: online.answer_md, sources: online.sources ?? [], confidence: online.confidence }); + const sourcesLine = online.sources?.length + ? "\n\nИсточники:\n" + online.sources.slice(0, 5).map((s) => `• ${s.title}: ${s.url}`).join("\n") + : ""; + setMessages((m) => [...m, { role: "assistant", text: `**Online Research** (confidence: ${(online.confidence * 100).toFixed(0)}%)\n\n${online.answer_md}${sourcesLine}` }]); + setMessages((m) => [...m, { role: "system", text: "Повтор запроса с online context…" }]); + const onlineMd = online.answer_md.slice(0, 8000); + const onlineSources = online.sources.slice(0, 10).map((s) => s.url); + const plan2 = await proposeActions( + pathToUse, + reportToUse, + goal, + designStyle.trim() || undefined, + trendsContext, + lastPlanJson ?? undefined, + lastPlanContext ?? undefined, + undefined, + undefined, + undefined, + undefined, + true, + onlineMd, + onlineSources, + true, + plan.error_code ?? undefined + ); + if (!plan2.ok) { + setMessages((m) => [...m, { role: "assistant", text: plan2.error ?? "Ошибка формирования плана после online context" }]); + return; + } + setLastPlanJson(plan2.plan_json ?? null); + setLastPlanContext(plan2.plan_context ?? null); + const summary = plan2.summary || "План от ИИ"; + if (plan2.protocol_version_used) { + setMessages((m) => [...m, { role: "assistant", text: `${summary} (protocol v${plan2.protocol_version_used}, online context used)` }]); + } else { + setMessages((m) => [...m, { role: "assistant", text: `${summary} (online context used)` }]); + } + setPendingActions(plan2.actions); + const allIdx: Record = {}; + plan2.actions.forEach((_, i) => { allIdx[i] = true; }); + setPendingActionIdx(allIdx); + if (plan2.actions.length) { + setMessages((m) => [...m, { role: "system", text: "Предпросмотр изменений…" }]); + await handlePreview(pathToUse, plan2.actions); + } + } catch (e) { + setMessages((m) => [...m, { role: "assistant", text: `Онлайн-поиск недоступен: ${String(e)}` }]); + } + return; + } else { + lastGoalWithOnlineFallbackRef.current = goal; + setMessages((m) => [...m, { role: "assistant", text: plan.error ?? "Ошибка формирования плана" }]); + setMessages((m) => [...m, { role: "system", text: "Попытка онлайн-поиска…" }]); + try { + const online = await researchAnswer(plan.online_fallback_suggested); + setLastOnlineAnswer({ answer_md: online.answer_md, sources: online.sources ?? [], confidence: online.confidence }); + const sourcesLine = online.sources?.length + ? "\n\nИсточники:\n" + online.sources.slice(0, 5).map((s) => `• ${s.title}: ${s.url}`).join("\n") + : ""; + setMessages((m) => [...m, { role: "assistant", text: `**Online Research** (confidence: ${(online.confidence * 100).toFixed(0)}%)\n\n${online.answer_md}${sourcesLine}` }]); + } catch (e) { + setMessages((m) => [...m, { role: "assistant", text: `Онлайн-поиск недоступен: ${String(e)}` }]); + } + } + } else { + setMessages((m) => [...m, { role: "assistant", text: plan.error ?? "Ошибка формирования плана" }]); + } return; } // Сохраняем план и контекст для Apply (когда пользователь напишет "ok" или "применяй") @@ -975,6 +1155,49 @@ export default function Tasks() { Тренды и рекомендации + {displayRequests.length > 0 && (
Запросы @@ -1420,6 +1643,62 @@ export default function Tasks() {

3. После изменений нажмите «Проверить целостность» для автоматической проверки типов, сборки и тестов.

)} + {lastOnlineAnswer && ( +
+
+
Online Research
+ {onlineAutoUseAsContext && ( + Auto-used ✓ + )} +
+
{lastOnlineAnswer.answer_md}
+ {lastOnlineAnswer.sources?.length ? ( +
+ Источники: +
    + {lastOnlineAnswer.sources.slice(0, 8).map((s, j) => ( +
  • + {s.title || s.url} +
  • + ))} +
+
+ ) : null} +
+ {!onlineAutoUseAsContext && ( + + )} + + {onlineAutoUseAsContext && ( + + )} +
+
+ )} {messages.length > 0 && messages.map((msg, i) => (
@@ -2063,6 +2342,65 @@ export default function Tasks() {
)} + {weeklyReportModalOpen && ( +
e.target === e.currentTarget && setWeeklyReportModalOpen(false)} + > +
e.stopPropagation()} + > +
+ Weekly Report +
+ {weeklyReport && !weeklyReportLoading && !weeklyReport.reportMd.startsWith("Ошибка") && ( + + )} + +
+
+
+ {weeklyReportLoading &&

Собираю трассы и генерирую отчёт…

} + {weeklyReport && !weeklyReportLoading &&
{weeklyReport.reportMd}
} + {!weeklyReport && !weeklyReportLoading &&

Нет данных.

} +
+
+
+ )} +
);