feat: 高德地图工具 + 多轮 bug 修复

新增:
- 高德地图 6 个内置工具: amap_poi_search, amap_geocode, amap_reverse_geocode, amap_route_plan, amap_travel_plan, amap_map_link
- CLI 子命令: ias tool amap (poi-search/geocode/route-plan/travel-plan/map-link)
- 工具优先级指南: build_capability_guide() 含 amap > weather > web_search 优先级
- DEFAULT_SYSTEM_PROMPT 内置工具优先级说明(不再依赖环境变量)

Bug 修复:
1. (HIGH) call_capability 参数契约修复 — 新增 unpack_call_params() 统一解包 prompt 字段
2. (MEDIUM) 天气 hours=0 不再调用逐时 API
3. (MEDIUM) 长期记忆 daemon 双写缓存+DB,无 DB 不丢失
4. (HIGH) 审批流程状态语义修复 — handle_reply 返回 ApprovalDecision,拒绝/过期不再当作通过
5. (HIGH) 审批有效期基于 expires_at 时间戳而非 receiver drop
6. (MEDIUM) 摘要保存改为 current_user_id 与读取一致
7. (MEDIUM) worker 审批通过后删除孤立 tool result,仅保留 approved_tool 状态放行

架构:
- daemon + worker IPC 架构 (Unix Domain Socket)
- build_capability_guide 共享函数消除 main.rs/worker.rs 重复
This commit is contained in:
2026-06-03 17:01:04 +08:00
parent e379e498b5
commit b2fe054eaf
23 changed files with 4367 additions and 263 deletions
+362
View File
@@ -0,0 +1,362 @@
//! Web 搜索工具 — Tavily Search API
//!
//! API: POST https://api.tavily.com/search
//! 认证: Authorization: Bearer <TAVILY_API_KEY>
//! 文档: https://docs.tavily.com/documentation/api-reference/endpoint/search
use reqwest::Client as HttpClient;
use serde::{Deserialize, Serialize};
use super::super::types::{RiskLevel, SkillResult, SkillSpec};
// ─── 请求 ───
#[derive(Debug, Serialize)]
struct SearchRequest {
query: String,
#[serde(skip_serializing_if = "Option::is_none")]
search_depth: Option<String>, // basic | advanced | fast | ultra-fast
#[serde(skip_serializing_if = "Option::is_none")]
chunks_per_source: Option<u32>, // 1-3, 仅 search_depth=advanced
#[serde(skip_serializing_if = "Option::is_none")]
max_results: Option<u32>, // 0-20
#[serde(skip_serializing_if = "Option::is_none")]
topic: Option<String>, // general | news | finance
#[serde(skip_serializing_if = "Option::is_none")]
time_range: Option<String>, // day | week | month | year | d | w | m | y
#[serde(skip_serializing_if = "Option::is_none")]
start_date: Option<String>, // YYYY-MM-DD
#[serde(skip_serializing_if = "Option::is_none")]
end_date: Option<String>, // YYYY-MM-DD
#[serde(skip_serializing_if = "Option::is_none")]
include_answer: Option<serde_json::Value>, // false | true(=basic) | "basic" | "advanced"
#[serde(skip_serializing_if = "Option::is_none")]
include_raw_content: Option<serde_json::Value>, // false | true(=markdown) | "markdown" | "text"
#[serde(skip_serializing_if = "Option::is_none")]
include_images: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
include_image_descriptions: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
include_favicon: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
include_domains: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
exclude_domains: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
country: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
auto_parameters: Option<bool>, // 默认 false
#[serde(skip_serializing_if = "Option::is_none")]
exact_match: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
include_usage: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
safe_search: Option<bool>, // Enterprise only
}
// ─── 响应 ───
#[derive(Debug, Deserialize)]
struct SearchResponse {
query: String,
#[serde(default)]
answer: Option<String>,
#[serde(default)]
images: Vec<serde_json::Value>,
#[serde(default)]
results: Vec<SearchResult>,
#[serde(default)]
response_time: serde_json::Value,
#[serde(default)]
auto_parameters: Option<serde_json::Value>,
#[serde(default)]
usage: Option<UsageInfo>,
#[serde(default)]
request_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SearchResult {
title: String,
url: String,
content: String,
#[serde(default)]
score: Option<f64>,
#[serde(default)]
raw_content: Option<String>,
#[serde(default)]
favicon: Option<String>,
#[serde(default)]
images: Vec<serde_json::Value>,
#[serde(default)]
published_date: Option<String>,
}
#[derive(Debug, Deserialize)]
struct UsageInfo {
#[serde(default)]
credits: Option<u32>,
}
// ─── 工具 spec ───
pub fn spec() -> SkillSpec {
SkillSpec {
name: "web_search".into(),
description:
"联网搜索最新信息。通过 Tavily API 搜索互联网,获取实时、准确的结果。\
适用于需要最新资讯、事实查询的场景。".into(),
risk_level: RiskLevel::Low,
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {"type": "string", "description": "搜索关键词"},
"max_results": {"type": "integer", "description": "最大结果数 1-20(默认5"},
"include_answer": {"type": "boolean", "description": "是否包含 AI 摘要(默认 true"},
"search_depth": {"type": "string", "enum": ["basic","advanced","fast","ultra-fast"],
"description": "basic 均衡 / advanced 深度(2 credits) / fast 快速 / ultra-fast 极速"},
"topic": {"type": "string", "enum": ["general","news","finance"],
"description": "general 通用 / news 新闻 / finance 财经"},
"time_range": {"type": "string", "enum": ["day","week","month","year"],
"description": "按发布时间过滤(仅 topic=news/finance 时有效)"},
"start_date": {"type": "string", "description": "起始日期 YYYY-MM-DD"},
"end_date": {"type": "string", "description": "结束日期 YYYY-MM-DD"},
"include_domains": {"type": "array", "items": {"type": "string"},
"description": "限定搜索域名列表"},
"exclude_domains": {"type": "array", "items": {"type": "string"},
"description": "排除搜索域名列表"},
"country": {"type": "string", "description": "优先指定国家的搜索结果(仅 topic=general"}
},
"required": ["query"]
}),
timeout_secs: 30,
}
}
// ─── 执行 ───
pub async fn execute(params: serde_json::Value) -> SkillResult {
let api_key = match std::env::var("TAVILY_API_KEY") {
Ok(k) if !k.is_empty() => k,
_ => return SkillResult::error("未配置 TAVILY_API_KEY 环境变量"),
};
// —— 解析参数 ——
let query = params
.get("query")
.and_then(|v| v.as_str())
.or_else(|| params.get("prompt").and_then(|v| v.as_str()))
.unwrap_or("");
if query.is_empty() {
return SkillResult::error("请提供 query 参数");
}
let max_results = params
.get("max_results")
.and_then(|v| v.as_u64())
.map(|n| n.min(20) as u32);
let include_answer = params
.get("include_answer")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let search_depth = params
.get("search_depth")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let topic = params
.get("topic")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let time_range = params
.get("time_range")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let start_date = params
.get("start_date")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let end_date = params
.get("end_date")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let include_domains = params.get("include_domains").and_then(|v| {
v.as_array().map(|arr| {
arr.iter()
.filter_map(|item| item.as_str().map(String::from))
.collect()
})
});
let exclude_domains = params.get("exclude_domains").and_then(|v| {
v.as_array().map(|arr| {
arr.iter()
.filter_map(|item| item.as_str().map(String::from))
.collect()
})
});
let country = params
.get("country")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// days 参数转换为 time_range(便捷兼容)
let time_range = time_range.or_else(|| {
params
.get("days")
.and_then(|v| v.as_u64())
.map(|d| format!("{}d", d))
});
// —— 发起请求 ——
let http = match HttpClient::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(e) => return SkillResult::error(format!("创建 HTTP 客户端失败: {}", e)),
};
let body = SearchRequest {
query: query.to_string(),
search_depth,
chunks_per_source: None,
max_results: Some(max_results.unwrap_or(5)),
topic,
time_range,
start_date,
end_date,
include_answer: if include_answer {
Some(serde_json::Value::Bool(true))
} else {
None
},
include_raw_content: None,
include_images: None,
include_image_descriptions: None,
include_favicon: None,
include_domains,
exclude_domains,
country,
auto_parameters: None,
exact_match: None,
include_usage: None,
safe_search: None,
};
match http
.post("https://api.tavily.com/search")
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
{
Ok(resp) => {
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return SkillResult::error(format!(
"Tavily API 错误 ({}): {:.300}",
status, text
));
}
let resp_text = resp.text().await.unwrap_or_default();
match serde_json::from_str::<SearchResponse>(&resp_text) {
Ok(data) => {
let mut output = String::new();
// AI 摘要
if let Some(ref answer) = data.answer {
if !answer.is_empty() {
output.push_str(&format!("📝 {}\n\n", answer));
}
}
// 搜索结果
if data.results.is_empty() {
output.push_str("未找到相关结果。");
} else {
for (i, r) in data.results.iter().enumerate() {
output.push_str(&format!(
"{}. **{}**\n 🔗 {}\n {}\n\n",
i + 1,
r.title,
r.url,
r.content
));
}
}
// 用量信息
let credits = data
.usage
.as_ref()
.and_then(|u| u.credits)
.map(|c| format!("{} credits", c))
.unwrap_or_default();
let rt = data.response_time;
output.push_str(&format!(
"{} | {} 条结果{}",
rt,
data.results.len(),
if credits.is_empty() {
String::new()
} else {
format!(" | {}", credits)
}
));
SkillResult::ok(output)
}
Err(e) => SkillResult::error(format!(
"解析 Tavily 响应失败: {} — 原始: {:.300}",
e, resp_text
)),
}
}
Err(e) => {
if e.is_timeout() {
SkillResult::error("Tavily 搜索超时")
} else {
SkillResult::error(format!("Tavily 请求失败: {}", e))
}
}
}
}