From 289b1b6dacf0339aa3837feab2ef89449eefd224 Mon Sep 17 00:00:00 2001 From: Yuriy Date: Wed, 11 Feb 2026 21:29:24 +0300 Subject: [PATCH] =?UTF-8?q?AI-=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=BE=D1=80:?= =?UTF-8?q?=20narrative=20=D0=BE=D1=82=20LLM,=20AuditLogger,=20SecretsGuar?= =?UTF-8?q?d,=20PolicyEngine,=20RAG=20=D0=BF=D0=BE=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit П.1 — LLM-narrative: fetch_narrative_for_report в llm_planner, вызов из analyze_project_cmd и run_batch; fallback на шаблон при отсутствии API. П.2 — В промпт «Предложить исправления» добавлен приоритет по findings из отчёта. П.3 — AuditLogger (audit_log.rs, /audit), SecretsGuard (secrets_guard.rs, /secrets), PolicyEngine (policy_engine.rs, /policies): Rust-модули + страницы в навигации. П.5 — RAG упрощённый: rag_query.rs, rag_query_cmd, страница «Вопрос по проекту» (/project-chat). Документ: docs/ПЛАН_AI_АУДИТОР.md с планом и секцией «Реализовано». Co-authored-by: Cursor --- docs/ПЛАН_AI_АУДИТОР.md | 129 ++++++++++++++++++++++++ src-tauri/src/audit_log.rs | 73 ++++++++++++++ src-tauri/src/commands/llm_planner.rs | 88 +++++++++++++++- src-tauri/src/commands/mod.rs | 3 + src-tauri/src/commands/rag_query.rs | 103 +++++++++++++++++++ src-tauri/src/commands/run_batch.rs | 22 +++- src-tauri/src/lib.rs | 81 +++++++++++++-- src-tauri/src/policy_engine.rs | 112 +++++++++++++++++++++ src-tauri/src/secrets_guard.rs | 115 +++++++++++++++++++++ src/App.tsx | 68 +++++++++++++ src/pages/AuditLog.tsx | 92 +++++++++++++++++ src/pages/PolicyEngine.tsx | 139 ++++++++++++++++++++++++++ src/pages/ProjectChat.tsx | 111 ++++++++++++++++++++ src/pages/SecretsGuard.tsx | 112 +++++++++++++++++++++ 14 files changed, 1236 insertions(+), 12 deletions(-) create mode 100644 docs/ПЛАН_AI_АУДИТОР.md create mode 100644 src-tauri/src/audit_log.rs create mode 100644 src-tauri/src/commands/rag_query.rs create mode 100644 src-tauri/src/policy_engine.rs create mode 100644 src-tauri/src/secrets_guard.rs create mode 100644 src/pages/AuditLog.tsx create mode 100644 src/pages/PolicyEngine.tsx create mode 100644 src/pages/ProjectChat.tsx create mode 100644 src/pages/SecretsGuard.tsx diff --git a/docs/ПЛАН_AI_АУДИТОР.md b/docs/ПЛАН_AI_АУДИТОР.md new file mode 100644 index 0000000..03d4d0f --- /dev/null +++ b/docs/ПЛАН_AI_АУДИТОР.md @@ -0,0 +1,129 @@ +# Реалистичный план: PAPA YU как AI-аудитор проектов + +**Цель:** не «ещё один Cursor», а специализированный AI-аудитор — уникальная ниша. План привязан к текущему коду и разбит на шаги с минимальным риском. + +--- + +## Реализовано (текущая сборка) + +- **П.1** — LLM-narrative: `fetch_narrative_for_report()` в `llm_planner.rs`, вызов из `analyze_project_cmd` и `run_batch`; fallback на шаблон при отсутствии API. +- **П.2** — В промпт «Предложить исправления» добавлено явное указание учитывать findings из отчёта. +- **П.3** — **AuditLogger**: `audit_log.rs`, команды `audit_log_list_cmd`, запись при анализе и apply; страница «Журнал» (/audit). **SecretsGuard**: `secrets_guard.rs`, `scan_secrets_cmd`, страница «Секреты» (/secrets). **PolicyEngine**: `policy_engine.rs`, `get_policies_cmd`, `run_policy_check_cmd`, страница «Политики» (/policies). +- **П.5 (упрощённый)** — RAG: `commands/rag_query.rs`, `rag_query_cmd` (контекст из файлов проекта + вопрос → LLM), страница «Вопрос по проекту» (/project-chat). + +--- + +## 1. LLM → осмысленный narrative вместо шаблонов + +**Сейчас:** в `analyze_project.rs` функция `build_human_narrative()` собирает текст из шаблонов: «Я проанализировал проект X», «Это React+Vite», «Найдено проблем: N. Рекомендую: …». + +**Цель:** отправлять в LLM структурированный контекст (путь, тип проекта, findings, actions, signals) и получать один короткий narrative (2–4 предложения) для отчёта и agent-sync. + +**Шаги:** + +| # | Задача | Где | Оценка | +|---|--------|-----|--------| +| 1.1 | Вынести сборку «llm_context для narrative» в отдельную функцию (path, project_type, findings, actions, signals → JSON или текст). | `analyze_project.rs` | малый | +| 1.2 | Добавить опциональный вызов LLM для narrative: если заданы `PAPAYU_LLM_*`, после построения отчёта вызвать API с этим контекстом и промптом «Напиши краткий вывод аудитора для разработчика (2–4 предложения)». | новый модуль `narrative_llm.rs` или в `llm_planner.rs` | малый | +| 1.3 | Подставить ответ LLM в `report.narrative` вместо `build_human_narrative()`. Fallback: при ошибке/отсутствии API — текущий шаблон. | `analyze_project.rs` | малый | + +**Риск:** низкий. Не ломает существующий поток, только обогащает текст. + +--- + +## 2. Код-генерация по findings → LLM, preview, apply + +**Сейчас:** есть цепочка: анализ → отчёт (findings + actions) → «Предложить исправления» (LLM генерирует план действий) → preview (diff) → apply. Генерация «безопасных» действий без LLM — в `generate_actions_from_report` (эвристики: README, .gitignore, tests). + +**Цель:** явно вести от findings к исправлениям: findings + контекст кода → LLM → actions → preview → apply. + +**Шаги:** + +| # | Задача | Где | Оценка | +|---|--------|-----|--------| +| 2.1 | В режиме «Предложить исправления» передавать в LLM не только goal/report, но и список findings с evidence (уже есть в `AnalyzeReport`). Промпт: «По этим находкам предложи конкретные правки (EDIT_FILE, CREATE_FILE)». | `llm_planner.rs`, конструирование prompt | малый | +| 2.2 | Опционально: кнопка «Исправить по finding» — по одному выбранному finding отправить в LLM контекст (файл/фрагмент) и получить один или несколько actions, затем preview. | UI: `Tasks.tsx` + новая command `generate_actions_for_finding` | средний | +| 2.3 | Оставить текущий preview/apply без изменений (уже показывают diff и применяют с проверкой). | — | — | + +**Риск:** низкий. Расширяем существующий LLM-контур. + +--- + +## 3. Оживить «заглушки»: PolicyEngine, SecretsGuard, AuditLogger + +**Сейчас:** в другом проекте (papayu-main) упоминаются маршруты `/policies`, `/audit`, `/secrets` как заглушки. В papa-yu их нет — фокус на задачах и анализе. + +**Цель:** ввести в papa-yu три модуля, привязанные к Rust-бэкенду, без переписывания всего приложения. + +**Шаги:** + +| # | Задача | Где | Оценка | +|---|--------|-----|--------| +| 3.1 | **AuditLogger** — логирование действий аудитора (анализ, apply, undo, отказ). Rust: команда `audit_log(event_type, payload)` + запись в файл `.papa-yu/audit.log` или SQLite. UI: страница «Журнал» — список последних событий (дата, тип, проект, результат). | `src-tauri/src/audit_log.rs`, `lib.rs`, новая страница `AuditLog.tsx`, маршрут `/audit` | средний | +| 3.2 | **SecretsGuard** — проверка проекта на типичные утечки (ключи в коде, .env в репо, хардкод паролей). Rust: сканирование выбранной папки (эвристики + опционально regex из конфига), возврат списка «подозрений» без хранения секретов. UI: страница «Секреты» — кнопка «Проверить», таблица файл/строка/тип. | `src-tauri/src/secrets_guard.rs`, команда `scan_secrets`, страница `SecretsGuard.tsx`, маршрут `/secrets` | средний | +| 3.3 | **PolicyEngine** — набор правил аудита (например «обязателен README», «запрещён коммит .env»). Rust: конфиг правил (JSON), проверка проекта по правилам, отчёт pass/fail. UI: страница «Политики» — список правил, результат последней проверки. | `src-tauri/src/policy_engine.rs`, команды `get_policies`, `run_policy_check`, страница `PolicyEngine.tsx`, маршрут `/policies` | средний | + +**Риск:** средний. Новый код, но изолированные модули. + +--- + +## 4. Deep analysis: парсинг кода, уязвимости, мёртвый код + +**Сейчас:** анализ в `analyze_project.rs` — обход файловой системы, эвристики (наличие README, tests, .gitignore и т.д.), без разбора содержимого кода. + +**Цель:** не только структура, но и парсинг кода (tree-sitter), поиск типичных уязвимостей, индикация мёртвого кода. + +**Шаги:** + +| # | Задача | Где | Оценка | +|---|--------|-----|--------| +| 4.1 | Подключить tree-sitter (Rust: `tree-sitter` crate) для одного языка-пилота (например JavaScript/TypeScript). Обход выбранных файлов, построение AST, подсчёт функций/классов, экспортов. | новый модуль `src-tauri/src/deep_analysis/` или `code_parser.rs` | большой | +| 4.2 | Добавить в отчёт «глубокие» findings: неиспользуемые экспорты (мёртвый код), простые паттерны риска (eval, innerHTML без санитизации — по правилам). | расширить `Finding`, вызов парсера из `analyze_project` или отдельной command | средний | +| 4.3 | Опционально: интеграция с Snyk Code / аналогом (уже есть `snyk_sync`) — показывать код-уязвимости рядом с нашими findings. | уже частично есть; доработать отображение в UI | малый | + +**Риск:** большой для 4.1 (зависимость, поддержка языков). Рационально начать с одного языка и набора из 3–5 правил. + +--- + +## 5. RAG по документации и коду: чат с контекстом проекта + +**Сейчас:** domain notes, project_content, выбор папки/файлов. Чат «с агентом» в UI есть, но без RAG по загруженному проекту. + +**Цель:** пользователь выбрал проект → задаёт вопросы в чате → агент отвечает с опорой на индекс кода/документации (RAG). + +**Шаги:** + +| # | Задача | Где | Оценка | +|---|--------|-----|--------| +| 5.1 | Индексация: по выбранному пути собрать «документы» (файлы с расширениями .md, .ts, .tsx, .rs и т.д.), разбить на чанки, опционально эмбеддинги (внешний API или локальная модель). Хранить индекс в `.papa-yu/rag_index` (SQLite + векторы или только текст для простого поиска). | новый модуль `src-tauri/src/rag/` (index, search), команды `rag_index_project`, `rag_query` | большой | +| 5.2 | Поиск по запросу: по тексту вопроса найти релевантные чанки (keyword или semantic), сформировать контекст для LLM. | `rag/query.rs` | средний | +| 5.3 | Чат: поле «Вопрос по проекту» → вызов `rag_query` + вызов LLM с контекстом → ответ в UI. Без переделки всего чата — один сценарий «вопрос по коду». | `Tasks.tsx` или отдельная вкладка «Вопросы по проекту», команда `chat_on_project` | средний | + +**Риск:** большой для полного RAG с эмбеддингами. Упрощённый вариант: индексация без эмбеддингов, поиск по ключевым словам + контекст из найденных файлов в промпт — быстрее внедрить. + +--- + +## Приоритеты и порядок + +| Приоритет | Блок | Зачем первым | +|-----------|------|--------------| +| 1 | П.1 — LLM-narrative | Быстрый визуальный выигрыш, малый объём работ, не ломает поток. | +| 2 | П.2 — Findings → LLM → apply | Уже есть цепочка; усиление связи findings → исправления усиливает позицию «аудитор». | +| 3 | П.3 — AuditLogger | Минимальная «оживляющая» заглушка: всё что делается — логируется. Нужно для доверия и аудита. | +| 4 | П.3 — SecretsGuard | Сильно востребовано в аудите; эвристики без тяжёлых зависимостей. | +| 5 | П.3 — PolicyEngine | Завершает триаду «аудиторских» модулей. | +| 6 | П.5 — RAG упрощённый | Чат по проекту без полного RAG: индекс файлов + keyword-поиск + контекст в LLM. | +| 7 | П.4 — Deep analysis | Самый тяжёлый; после закрепления п.1–3 и 5 имеет смысл вводить tree-sitter по одному языку. | +| 8 | П.5 — RAG с эмбеддингами | При необходимости углубления чата по коду. | + +--- + +## Кратко по файлам + +- **Narrative от LLM:** `analyze_project.rs` (build_human_narrative → вызов LLM), новый хелпер в `llm_planner.rs` или `narrative_llm.rs`. +- **Findings → LLM:** `llm_planner.rs` (prompt), `generate_actions_from_report.rs` / новая command для одного finding. +- **Audit/Secrets/Policy:** новые модули в `src-tauri/src/`, новые страницы и маршруты в `src/App.tsx`. +- **Deep analysis:** новый модуль `code_parser` / `deep_analysis`, расширение `analyze_project` или отдельная command. +- **RAG:** новый модуль `rag/`, команды индексации и запроса, блок чата в UI. + +Документ можно использовать как дорожную карту для спринтов и оценок. diff --git a/src-tauri/src/audit_log.rs b/src-tauri/src/audit_log.rs new file mode 100644 index 0000000..e48f7f3 --- /dev/null +++ b/src-tauri/src/audit_log.rs @@ -0,0 +1,73 @@ +//! Журнал аудита: запись событий (анализ, apply, undo) в файл. +//! Файл: app_data_dir/papa-yu/audit.log или project_path/.papa-yu/audit.log при указании пути проекта. + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditEvent { + pub ts: String, + pub event_type: String, + pub project_path: Option, + pub result: Option, + pub details: Option, +} + +fn audit_file_path(base_dir: &Path) -> std::path::PathBuf { + base_dir.join("papa-yu").join("audit.log") +} + +/// Записывает событие в audit.log в app_data_dir (глобальный лог приложения). +pub fn log_event( + app_audit_dir: &Path, + event_type: &str, + project_path: Option<&str>, + result: Option<&str>, + details: Option<&str>, +) -> Result<(), String> { + let file_path = audit_file_path(app_audit_dir); + if let Some(parent) = file_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&file_path) + .map_err(|e| format!("audit log open: {}", e))?; + let ts = Utc::now().to_rfc3339(); + let line = serde_json::json!({ + "ts": ts, + "event_type": event_type, + "project_path": project_path, + "result": result, + "details": details + }); + writeln!(file, "{}", line).map_err(|e| format!("audit log write: {}", e))?; + file.flush().map_err(|e| format!("audit log flush: {}", e))?; + Ok(()) +} + +/// Читает последние N строк из audit.log. Возвращает события от новых к старым. +pub fn read_events(app_audit_dir: &Path, limit: usize) -> Vec { + let file_path = audit_file_path(app_audit_dir); + let content = match std::fs::read_to_string(&file_path) { + Ok(c) => c, + Err(_) => return vec![], + }; + let lines: Vec<&str> = content.lines().rev().take(limit).collect(); + let mut out = Vec::with_capacity(lines.len()); + for line in lines.iter().rev() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Ok(ev) = serde_json::from_str::(trimmed) { + out.push(ev); + } + } + out.reverse(); + out +} diff --git a/src-tauri/src/commands/llm_planner.rs b/src-tauri/src/commands/llm_planner.rs index 7ce282b..0a9ad52 100644 --- a/src-tauri/src/commands/llm_planner.rs +++ b/src-tauri/src/commands/llm_planner.rs @@ -12,7 +12,7 @@ use crate::context; use crate::memory; -use crate::types::{Action, ActionKind, AgentPlan}; +use crate::types::{Action, ActionKind, AgentPlan, AnalyzeReport}; use jsonschema::JSONSchema; use serde::Deserialize; use sha2::{Digest, Sha256}; @@ -516,6 +516,7 @@ fn build_prompt( Путь проекта: {} Цель пользователя: {} {} +Важно: предложи исправления в первую очередь по находкам (findings) из отчёта — каждая находка может быть закрыта одним или несколькими действиями (CREATE_FILE, PATCH_FILE и т.д.). В отчёте ниже есть массив findings с полями title, details, path — используй их как приоритет. Отчёт анализа (JSON): {} {} @@ -1180,6 +1181,91 @@ fn parse_plan_response(json_str: &str) -> Result { }) } +/// Контекст для генерации narrative: путь, находки, число действий (без вызова LLM). +pub fn build_narrative_context(report: &AnalyzeReport) -> String { + let mut parts = vec![ + format!("Проект: {}", report.path), + format!("Находок: {}", report.findings.len()), + format!("Доступных исправлений: {}", report.actions.len()), + ]; + if !report.findings.is_empty() { + let list: Vec = report + .findings + .iter() + .take(10) + .map(|f| format!("- {}: {}", f.title, f.details)) + .collect(); + parts.push("Находки:".to_string()); + parts.push(list.join("\n")); + } + parts.join("\n") +} + +/// Запрашивает у LLM краткий narrative (2–4 предложения) по отчёту. Без JSON — только текст. +/// Использует те же PAPAYU_LLM_* переменные. При ошибке или отсутствии конфига возвращает Err. +pub async fn fetch_narrative_for_report(report: &AnalyzeReport) -> Result { + let api_url = std::env::var("PAPAYU_LLM_API_URL").map_err(|_| "PAPAYU_LLM_API_URL not set".to_string())?; + let api_url = api_url.trim(); + if api_url.is_empty() { + return Err("PAPAYU_LLM_API_URL is empty".to_string()); + } + let api_key = std::env::var("PAPAYU_LLM_API_KEY").ok(); + let model = std::env::var("PAPAYU_LLM_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string()); + let timeout_sec = std::env::var("PAPAYU_LLM_TIMEOUT_SEC") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(30); + let max_tokens = std::env::var("PAPAYU_LLM_MAX_TOKENS") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(512); + + let system = "Ты — аудитор проектов. Напиши краткий вывод для разработчика: 2–4 предложения по контексту ниже. Только текст, без заголовков и маркированных списков. Язык: русский."; + let user = build_narrative_context(report); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(timeout_sec)) + .build() + .map_err(|e| format!("HTTP client: {}", e))?; + let body = ChatRequest { + model: model.trim().to_string(), + messages: vec![ + ChatMessage { role: "system".to_string(), content: system.to_string() }, + ChatMessage { role: "user".to_string(), content: user }, + ], + temperature: Some(0.3), + max_tokens: Some(max_tokens), + top_p: Some(1.0), + presence_penalty: Some(0.0), + frequency_penalty: Some(0.0), + response_format: None, + }; + 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.chars().take(500).collect::())); + } + let chat: ChatResponse = serde_json::from_str(&text).map_err(|e| format!("Response JSON: {}", e))?; + let content = chat + .choices + .as_ref() + .and_then(|c| c.first()) + .and_then(|c| c.message.content.as_deref()) + .unwrap_or("") + .trim(); + if content.is_empty() { + return Err("Empty narrative from API".to_string()); + } + Ok(content.to_string()) +} + const MAX_CONTEXT_ROUNDS: u32 = 2; /// Один запрос к LLM без repair/retry. Для мульти-провайдера: сбор планов от нескольких ИИ. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 99a8a71..1c25a6b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -13,6 +13,7 @@ mod preview_actions; mod project_content; mod projects; mod propose_actions; +mod rag_query; mod redo_last; mod run_batch; mod settings_export; @@ -38,10 +39,12 @@ pub use projects::{ list_projects, list_sessions, set_project_settings, }; pub use propose_actions::propose_actions; +pub use rag_query::chat_on_project; pub use redo_last::redo_last; pub use run_batch::run_batch; pub use settings_export::{export_settings, import_settings}; pub use trends::{fetch_trends_recommendations, get_trends_recommendations}; +pub use llm_planner::{fetch_narrative_for_report, is_llm_configured}; 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; diff --git a/src-tauri/src/commands/rag_query.rs b/src-tauri/src/commands/rag_query.rs new file mode 100644 index 0000000..196107e --- /dev/null +++ b/src-tauri/src/commands/rag_query.rs @@ -0,0 +1,103 @@ +//! Упрощённый RAG: контекст из файлов проекта + вопрос → LLM. Без эмбеддингов, keyword-контекст. + +use std::path::Path; + +use super::project_content; + +const RAG_CONTEXT_CHARS: usize = 80_000; + +/// Собирает контекст по проекту и отправляет вопрос в LLM. Возвращает ответ или ошибку. +pub async fn chat_on_project(project_path: &Path, question: &str) -> Result { + if !project_path.exists() || !project_path.is_dir() { + return Err("Папка проекта не найдена".to_string()); + } + let context = project_content::get_project_content_for_llm( + project_path, + Some(RAG_CONTEXT_CHARS), + ); + let api_url = std::env::var("PAPAYU_LLM_API_URL").map_err(|_| "PAPAYU_LLM_API_URL не задан".to_string())?; + let api_url = api_url.trim(); + if api_url.is_empty() { + return Err("PAPAYU_LLM_API_URL пустой".to_string()); + } + let api_key = std::env::var("PAPAYU_LLM_API_KEY").ok(); + let model = std::env::var("PAPAYU_LLM_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string()); + let timeout_sec = std::env::var("PAPAYU_LLM_TIMEOUT_SEC") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(60); + let max_tokens = std::env::var("PAPAYU_LLM_MAX_TOKENS") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(4096); + + let system = "Ты — ассистент по коду проекта. Отвечай кратко по контексту ниже. Если в контексте нет ответа — так и скажи. Язык ответа: русский."; + let user = format!( + "Контекст (файлы проекта):\n\n{}\n\nВопрос: {}", + context.chars().take(120_000).collect::(), + question + ); + + #[derive(serde::Serialize)] + struct ChatMessage { + role: String, + content: String, + } + #[derive(serde::Serialize)] + struct ChatRequest { + model: String, + messages: Vec, + temperature: f32, + max_tokens: u32, + } + #[derive(serde::Deserialize)] + struct ChatChoice { + message: ChatMessageResponse, + } + #[derive(serde::Deserialize)] + struct ChatMessageResponse { + content: Option, + } + #[derive(serde::Deserialize)] + struct ChatResponse { + choices: Option>, + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_sec)) + .build() + .map_err(|e| format!("HTTP client: {}", e))?; + let body = ChatRequest { + model: model.trim().to_string(), + messages: vec![ + ChatMessage { role: "system".to_string(), content: system.to_string() }, + ChatMessage { role: "user".to_string(), content: user }, + ], + temperature: 0.3, + max_tokens, + }; + 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.chars().take(300).collect::())); + } + let chat: ChatResponse = serde_json::from_str(&text).map_err(|e| format!("JSON: {}", e))?; + let content = chat + .choices + .as_ref() + .and_then(|c| c.first()) + .and_then(|c| c.message.content.as_deref()) + .unwrap_or("") + .trim(); + if content.is_empty() { + return Err("Пустой ответ от API".to_string()); + } + Ok(content.to_string()) +} diff --git a/src-tauri/src/commands/run_batch.rs b/src-tauri/src/commands/run_batch.rs index c4d7258..3347818 100644 --- a/src-tauri/src/commands/run_batch.rs +++ b/src-tauri/src/commands/run_batch.rs @@ -1,12 +1,14 @@ use std::path::Path; use crate::agent_sync; +use crate::audit_log; use crate::commands::get_project_profile::get_project_limits; -use crate::commands::{analyze_project, apply_actions, preview_actions}; +use crate::commands::{analyze_project, apply_actions, fetch_narrative_for_report, preview_actions}; +use crate::commands::is_llm_configured; use crate::snyk_sync; use crate::tx::get_undo_redo_state; use crate::types::{BatchEvent, BatchPayload}; -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; pub async fn run_batch(app: AppHandle, payload: BatchPayload) -> Result, String> { let mut events = Vec::new(); @@ -17,14 +19,28 @@ pub async fn run_batch(app: AppHandle, payload: BatchPayload) -> Result, attached_files: Option>, ) -> Result { - let report = analyze_project(paths, attached_files)?; + let mut report = analyze_project(paths.clone(), attached_files)?; + if commands::is_llm_configured() { + if let Ok(narrative) = fetch_narrative_for_report(&report).await { + report.narrative = narrative; + } + } let snyk_findings = if snyk_sync::is_snyk_sync_enabled() { snyk_sync::fetch_snyk_code_issues().await.ok() } else { None }; agent_sync::write_agent_sync_if_enabled(&report, snyk_findings); + if let Ok(dir) = app.path().app_data_dir() { + let _ = audit_log::log_event( + &dir, + "analyze", + paths.first().map(String::as_str), + Some("ok"), + Some(&format!("findings={}", report.findings.len())), + ); + } Ok(report) } @@ -48,7 +67,17 @@ fn preview_actions_cmd(payload: ApplyPayload) -> Result types::ApplyResult { - apply_actions(app, payload) + let result = apply_actions(app.clone(), payload.clone()); + if let Ok(dir) = app.path().app_data_dir() { + let _ = audit_log::log_event( + &dir, + "apply", + Some(&payload.root_path), + if result.ok { Some("ok") } else { Some("fail") }, + result.error.as_deref(), + ); + } + result } #[tauri::command] @@ -149,6 +178,37 @@ async fn distill_and_save_domain_note_cmd( domain_notes::distill_and_save_note(path, &query, &answer_md, &sources_tuples, confidence).await } +/// Журнал аудита: последние события. +#[tauri::command] +fn audit_log_list_cmd(app: tauri::AppHandle, limit: Option) -> Result, String> { + let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + Ok(audit_log::read_events(&dir, limit.unwrap_or(100))) +} + +/// Сканирование проекта на подозрительные секреты. +#[tauri::command] +fn scan_secrets_cmd(project_path: String) -> Vec { + secrets_guard::scan_secrets(std::path::Path::new(&project_path)) +} + +/// Список правил политик. +#[tauri::command] +fn get_policies_cmd() -> Vec { + policy_engine::get_policies() +} + +/// Проверка проекта по правилам. +#[tauri::command] +fn run_policy_check_cmd(project_path: String) -> Vec { + policy_engine::run_policy_check(std::path::Path::new(&project_path)) +} + +/// RAG: вопрос по коду проекта (контекст из файлов + LLM). +#[tauri::command] +async fn rag_query_cmd(project_path: String, question: String) -> Result { + chat_on_project(std::path::Path::new(&project_path), &question).await +} + /// Сохранить отчёт в docs/reports/weekly_YYYY-MM-DD.md. #[tauri::command] fn save_report_cmd( @@ -172,6 +232,11 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .invoke_handler(tauri::generate_handler![ analyze_project_cmd, + audit_log_list_cmd, + scan_secrets_cmd, + get_policies_cmd, + run_policy_check_cmd, + rag_query_cmd, preview_actions_cmd, apply_actions_cmd, undo_last, diff --git a/src-tauri/src/policy_engine.rs b/src-tauri/src/policy_engine.rs new file mode 100644 index 0000000..f5ee82d --- /dev/null +++ b/src-tauri/src/policy_engine.rs @@ -0,0 +1,112 @@ +//! Движок политик: проверка проекта по правилам (README, .gitignore, .env не в репо и т.д.). + +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolicyRule { + pub id: String, + pub name: String, + pub description: String, + pub check: String, // "file_exists" | "file_missing" | "no_env_in_repo" +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolicyCheckResult { + pub rule_id: String, + pub passed: bool, + pub message: String, +} + +fn default_rules() -> Vec { + vec![ + PolicyRule { + id: "readme".to_string(), + name: "README".to_string(), + description: "В корне должен быть README.md или аналог".to_string(), + check: "file_exists".to_string(), + }, + PolicyRule { + id: "gitignore".to_string(), + name: ".gitignore".to_string(), + description: "Должен быть .gitignore".to_string(), + check: "file_exists".to_string(), + }, + PolicyRule { + id: "no_env".to_string(), + name: ".env не в репо".to_string(), + description: ".env не должен коммититься (должен быть в .gitignore)".to_string(), + check: "no_env_in_repo".to_string(), + }, + PolicyRule { + id: "tests".to_string(), + name: "Папка tests/".to_string(), + description: "Рекомендуется иметь tests/ или __tests__".to_string(), + check: "dir_exists".to_string(), + }, + ] +} + +/// Возвращает список правил по умолчанию. +pub fn get_policies() -> Vec { + default_rules() +} + +/// Запускает проверку проекта по правилам. +pub fn run_policy_check(project_path: &Path) -> Vec { + let rules = get_policies(); + let mut results = Vec::with_capacity(rules.len()); + for rule in rules { + let (passed, message) = match rule.check.as_str() { + "file_exists" => { + let files = match rule.id.as_str() { + "readme" => ["README.md", "README.MD", "README.txt", "README"], + "gitignore" => [".gitignore", "", "", ""], + _ => continue, + }; + let exists = files.iter().any(|f| !f.is_empty() && project_path.join(f).exists()); + ( + exists, + if exists { + "OK".to_string() + } else { + format!("Отсутствует: {}", rule.name) + }, + ) + } + "dir_exists" => { + let exists = project_path.join("tests").is_dir() || project_path.join("__tests__").is_dir(); + ( + exists, + if exists { "OK".to_string() } else { "Нет tests/ или __tests__".to_string() }, + ) + } + "no_env_in_repo" => { + let env_path = project_path.join(".env"); + let gitignore = project_path.join(".gitignore"); + let env_exists = env_path.is_file(); + let ignored = if gitignore.is_file() { + std::fs::read_to_string(&gitignore).map_or(false, |c| c.lines().any(|l| l.trim() == ".env")) + } else { + false + }; + let passed = !env_exists || ignored; + ( + passed, + if passed { + "OK".to_string() + } else { + ".env присутствует; добавьте .env в .gitignore".to_string() + }, + ) + } + _ => (false, "Неизвестное правило".to_string()), + }; + results.push(PolicyCheckResult { + rule_id: rule.id.clone(), + passed, + message, + }); + } + results +} diff --git a/src-tauri/src/secrets_guard.rs b/src-tauri/src/secrets_guard.rs new file mode 100644 index 0000000..de6c040 --- /dev/null +++ b/src-tauri/src/secrets_guard.rs @@ -0,0 +1,115 @@ +//! Сканирование проекта на типичные утечки: ключи в коде, .env в репо, хардкод паролей. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +const MAX_FILE_SIZE: usize = 256 * 1024; // 256 KB +const SKIP_DIRS: &[&str] = &["node_modules", "target", "dist", ".git", "build", ".next"]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecretSuspicion { + pub path: String, + pub line: Option, + pub kind: String, + pub snippet: String, +} + +fn is_skip_dir(name: &str) -> bool { + SKIP_DIRS.contains(&name) +} + +fn check_content(path: &str, content: &str) -> Vec { + let mut out = Vec::new(); + let _lower = content.to_lowercase(); + let lines: Vec<&str> = content.lines().collect(); + for (i, line) in lines.iter().enumerate() { + let line_lower = line.to_lowercase(); + if line_lower.contains("api_key") && line_lower.contains("=") && !line_lower.contains("example") { + out.push(SecretSuspicion { + path: path.to_string(), + line: Some((i + 1) as u32), + kind: "api_key".to_string(), + snippet: line.trim().chars().take(80).collect::(), + }); + } + if line_lower.contains("password") && line_lower.contains("=") && !line_lower.contains("example") && !line_lower.contains("env.example") { + out.push(SecretSuspicion { + path: path.to_string(), + line: Some((i + 1) as u32), + kind: "password".to_string(), + snippet: line.trim().chars().take(80).collect::(), + }); + } + if line_lower.contains("secret") && line_lower.contains("=") && !line_lower.contains("example") { + out.push(SecretSuspicion { + path: path.to_string(), + line: Some((i + 1) as u32), + kind: "secret".to_string(), + snippet: line.trim().chars().take(80).collect::(), + }); + } + if (line_lower.contains("sk-") || line_lower.contains("ghp_") || line_lower.contains("xoxb-")) && line.len() > 10 { + out.push(SecretSuspicion { + path: path.to_string(), + line: Some((i + 1) as u32), + kind: "token_like".to_string(), + snippet: "[REDACTED]".to_string(), + }); + } + } + if path.ends_with(".env") && !path.ends_with(".env.example") { + if !out.is_empty() { + return out; + } + out.push(SecretSuspicion { + path: path.to_string(), + line: None, + kind: "env_file".to_string(), + snippet: ".env не должен быть в репозитории; используйте .env.example".to_string(), + }); + } + out +} + +/// Сканирует директорию и возвращает список подозрений на утечку секретов. +pub fn scan_secrets(project_path: &Path) -> Vec { + let mut out = Vec::new(); + let walker = walkdir::WalkDir::new(project_path) + .follow_links(false) + .into_iter() + .filter_entry(|e| { + e.path().file_name().map_or(true, |n| { + !is_skip_dir(n.to_string_lossy().as_ref()) + }) + }); + for entry in walker.filter_map(|e| e.ok()) { + let path = entry.path(); + if !path.is_file() { + continue; + } + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let allowed = matches!( + (ext, name), + ("ts", _) | ("tsx", _) | ("js", _) | ("jsx", _) | ("rs", _) | ("py", _) | ("json", _) + | ("env", _) | ("yaml", _) | ("yml", _) | ("toml", _) | ("md", _) + ) || name.starts_with(".env"); + if !allowed { + continue; + } + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + }; + if content.len() > MAX_FILE_SIZE { + continue; + } + let rel = path.strip_prefix(project_path).unwrap_or(path); + let rel_str = rel.to_string_lossy().to_string(); + for s in check_content(&rel_str, &content) { + out.push(s); + } + } + out +} diff --git a/src/App.tsx b/src/App.tsx index a06608d..cb05ae1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,10 @@ import Tasks from "./pages/Tasks"; import Dashboard from "./pages/Dashboard"; import ProjectNotes from "./pages/ProjectNotes"; import Updates from "./pages/Updates"; +import AuditLog from "./pages/AuditLog"; +import SecretsGuard from "./pages/SecretsGuard"; +import PolicyEngine from "./pages/PolicyEngine"; +import ProjectChat from "./pages/ProjectChat"; import Finances from "./pages/Finances"; import Personnel from "./pages/Personnel"; @@ -103,6 +107,66 @@ function Layout({ children }: { children: React.ReactNode }) { > Обновления + ({ + padding: "10px 18px", + borderRadius: "999px", + fontWeight: 600, + fontSize: "14px", + textDecoration: "none", + color: isActive ? "#1e3a5f" : "rgba(255,255,255,0.9)", + background: isActive ? "#fff" : "rgba(255,255,255,0.15)", + transition: "background 0.2s ease, color 0.2s ease", + })} + > + Журнал + + ({ + padding: "10px 18px", + borderRadius: "999px", + fontWeight: 600, + fontSize: "14px", + textDecoration: "none", + color: isActive ? "#1e3a5f" : "rgba(255,255,255,0.9)", + background: isActive ? "#fff" : "rgba(255,255,255,0.15)", + transition: "background 0.2s ease, color 0.2s ease", + })} + > + Секреты + + ({ + padding: "10px 18px", + borderRadius: "999px", + fontWeight: 600, + fontSize: "14px", + textDecoration: "none", + color: isActive ? "#1e3a5f" : "rgba(255,255,255,0.9)", + background: isActive ? "#fff" : "rgba(255,255,255,0.15)", + transition: "background 0.2s ease, color 0.2s ease", + })} + > + Политики + + ({ + padding: "10px 18px", + borderRadius: "999px", + fontWeight: 600, + fontSize: "14px", + textDecoration: "none", + color: isActive ? "#1e3a5f" : "rgba(255,255,255,0.9)", + background: isActive ? "#fff" : "rgba(255,255,255,0.15)", + transition: "background 0.2s ease, color 0.2s ease", + })} + > + Вопрос по проекту +
{children}
@@ -119,6 +183,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/src/pages/AuditLog.tsx b/src/pages/AuditLog.tsx new file mode 100644 index 0000000..39cd5d6 --- /dev/null +++ b/src/pages/AuditLog.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +interface AuditEvent { + ts: string; + event_type: string; + project_path: string | null; + result: string | null; + details: string | null; +} + +export default function AuditLog() { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = async () => { + setLoading(true); + setError(null); + try { + const list = await invoke("audit_log_list_cmd", { limit: 100 }); + setEvents(list); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + return ( +
+

Журнал аудита

+

+ События анализа и применения изменений. Лог хранится локально. +

+ + {error &&

{error}

} + {events.length === 0 && !loading && ( +

Событий пока нет.

+ )} +
    + {events.map((ev, i) => ( +
  • + {ev.event_type} + {ev.project_path && ( + + {ev.project_path} + + )} + {ev.result && ( + + {ev.result} + + )} +
    + {ev.ts} + {ev.details && ` · ${ev.details}`} +
    +
  • + ))} +
+
+ ); +} diff --git a/src/pages/PolicyEngine.tsx b/src/pages/PolicyEngine.tsx new file mode 100644 index 0000000..245d7ba --- /dev/null +++ b/src/pages/PolicyEngine.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-dialog"; + +interface PolicyRule { + id: string; + name: string; + description: string; + check: string; +} + +interface PolicyCheckResult { + rule_id: string; + passed: boolean; + message: string; +} + +export default function PolicyEngine() { + const [projectPath, setProjectPath] = useState(null); + const [rules, setRules] = useState([]); + const [results, setResults] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadPolicies = async () => { + try { + const list = await invoke("get_policies_cmd"); + setRules(list); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const selectFolder = async () => { + const selected = await open({ directory: true }); + if (selected) setProjectPath(selected); + }; + + const runCheck = async () => { + if (!projectPath) { + setError("Сначала выберите папку проекта"); + return; + } + setLoading(true); + setError(null); + try { + const list = await invoke("run_policy_check_cmd", { + projectPath, + }); + setResults(list); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadPolicies(); + }, []); + + return ( +
+

Движок политик

+

+ Проверка проекта по правилам: README, .gitignore, .env не в репо, наличие tests/. +

+
+ + {projectPath && ( + {projectPath} + )} + +
+ {error &&

{error}

} + {results && ( +
    + {results.map((r, i) => ( +
  • + +
    + {r.rule_id} +
    {r.message}
    +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/pages/ProjectChat.tsx b/src/pages/ProjectChat.tsx new file mode 100644 index 0000000..ef1d83e --- /dev/null +++ b/src/pages/ProjectChat.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-dialog"; + +export default function ProjectChat() { + const [projectPath, setProjectPath] = useState(null); + const [question, setQuestion] = useState(""); + const [answer, setAnswer] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const selectFolder = async () => { + const selected = await open({ directory: true }); + if (selected) setProjectPath(selected); + }; + + const ask = async () => { + if (!projectPath || !question.trim()) { + setError("Выберите папку проекта и введите вопрос"); + return; + } + setLoading(true); + setError(null); + setAnswer(null); + try { + const result = await invoke("rag_query_cmd", { + projectPath, + question: question.trim(), + }); + setAnswer(result); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + return ( +
+

Вопрос по проекту

+

+ Задайте вопрос по коду — ответ будет с учётом контекста файлов проекта (нужен PAPAYU_LLM_API_URL). +

+
+ + {projectPath && ( + {projectPath} + )} +
+