papayu/src-tauri/src/online_research/search.rs
Yuriy 764003fc09 Commit X4: Auto inject Online Research summary into plan context
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<String>` 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 <cursoragent@cursor.com>
2026-01-31 14:39:40 +03:00

69 lines
2.2 KiB
Rust

//! 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<String>,
}
/// Tavily Search API: POST https://api.tavily.com/search
pub async fn tavily_search(query: &str, max_results: usize) -> Result<Vec<SearchResult>, 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<SearchResult> = 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)
}