fix: reject unrelated web research results

This commit is contained in:
冰朔 2026-07-22 16:47:34 +08:00
parent 46d287510d
commit 3e1b0bd80f
3 changed files with 214 additions and 16 deletions

View File

@ -23,4 +23,6 @@ Use `magic_brush` as the executable skill for work inside the active vault. Do n
5. Report each completed step from the tool results. If a step fails, state the exact failed tool and missing or invalid argument; do not claim later steps succeeded.
6. Do not repeat a step whose successful result is already present in the conversation.
7. Ask for confirmation immediately before permanent deletion, even if earlier read/create/edit steps have already completed.
8. For web research, search first, read the most relevant result pages, distinguish page claims from verified facts, and include the source links in the final answer. Never claim that search snippets alone prove the page contents.
8. For web research, search first and wait for the real result before selecting a URL. Never place `search_web` and a dependent `read_web_page` in the same `magic_brush` call, because the later step cannot know the earlier result yet. In the next tool round, read the most relevant result pages, distinguish page claims from verified facts, and include the source links in the final answer. Never invent or predict a result URL, and never claim that search snippets alone prove the page contents.
9. If a result page rejects automated reading with 401, 403, or 429, do not describe the search tool or model API as unconfigured. Return to the actual search results and try another relevant public source. A blocked page is one unavailable source, not failure of the entire research task.
10. If the user asks only to test web search without naming a subject, choose a stable public topic with an obvious authoritative page. Do not use HoloLake or other local/private project names as the test query unless the user specifically asks for them; a private or unindexed project may correctly have no public results.

View File

@ -1,6 +1,7 @@
use crate::ai_agents::AiAgentStreamEvent;
use crate::ai_models::AiModelStreamRequest;
use serde::Deserialize;
use std::collections::HashSet;
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::path::{Path, PathBuf};
@ -546,20 +547,82 @@ fn search_web_from_tool_args(args: &serde_json::Value) -> Result<String, String>
.and_then(serde_json::Value::as_u64)
.unwrap_or(6)
.clamp(1, 10) as usize;
let mut items = Vec::new();
let mut errors = Vec::new();
match fetch_360_web_search(query) {
Ok(results) => items.extend(results),
Err(error) => errors.push(error),
}
match fetch_bing_web_search(query) {
Ok(results) => items.extend(results),
Err(error) => errors.push(error),
}
if items.is_empty() && errors.len() == 2 {
return Err(format!("联网搜索服务暂时不可用:{}", errors.join("")));
}
format_web_search_results(query, items, limit)
}
fn fetch_bing_web_search(query: &str) -> Result<Vec<WebSearchItem>, String> {
let mut url = reqwest::Url::parse("https://www.bing.com/search")
.map_err(|error| format!("Failed to prepare web search: {error}"))?;
.map_err(|error| format!("无法准备 Bing 搜索:{error}"))?;
url.query_pairs_mut()
.append_pair("format", "rss")
.append_pair("q", query);
let body = public_web_get(url)
.map_err(|error| format!("联网搜索失败:{error}"))?
.map_err(|error| format!("Bing 搜索失败:{error}"))?
.error_for_status()
.map_err(|error| format!("联网搜索服务返回错误:{error}"))?
.map_err(|error| format!("Bing 搜索返回错误:{error}"))?
.text()
.map_err(|error| format!("读取联网搜索结果失败:{error}"))?;
let rss: WebSearchRss =
quick_xml::de::from_str(&body).map_err(|error| format!("解析联网搜索结果失败:{error}"))?;
format_web_search_results(query, rss.channel.item, limit)
.map_err(|error| format!("读取 Bing 搜索结果失败:{error}"))?;
let rss: WebSearchRss = quick_xml::de::from_str(&body)
.map_err(|error| format!("解析 Bing 搜索结果失败:{error}"))?;
Ok(rss.channel.item)
}
fn fetch_360_web_search(query: &str) -> Result<Vec<WebSearchItem>, String> {
let mut url = reqwest::Url::parse("https://www.so.com/s")
.map_err(|error| format!("无法准备 360 搜索:{error}"))?;
url.query_pairs_mut().append_pair("q", query);
let body = public_web_get(url)
.map_err(|error| format!("360 搜索失败:{error}"))?
.error_for_status()
.map_err(|error| format!("360 搜索返回错误:{error}"))?
.text()
.map_err(|error| format!("读取 360 搜索结果失败:{error}"))?;
Ok(parse_360_web_search(&body))
}
fn parse_360_web_search(body: &str) -> Vec<WebSearchItem> {
let blocks = regex::Regex::new(r#"(?is)<li class=[\"']res-list[\"'].*?</li>"#)
.expect("valid 360 result block regex");
let heading = regex::Regex::new(
r#"(?is)data-mdurl=[\"']([^\"']+)[\"'][^>]*>(.*?)</a>\s*</h3>"#,
)
.expect("valid 360 result heading regex");
let description = regex::Regex::new(
r#"(?is)<p[^>]+class=[\"'][^\"']*(?:res-desc|res-desc__text)[^\"']*[\"'][^>]*>(.*?)</p>"#,
)
.expect("valid 360 result description regex");
blocks
.find_iter(body)
.filter_map(|block| {
let captures = heading.captures(block.as_str())?;
let link = plain_web_text(captures.get(1)?.as_str());
if !link.starts_with("https://") {
return None;
}
Some(WebSearchItem {
title: plain_web_text(captures.get(2)?.as_str()),
link,
description: description
.captures(block.as_str())
.and_then(|value| value.get(1))
.map(|value| plain_web_text(value.as_str()))
.unwrap_or_default(),
})
})
.collect()
}
fn format_web_search_results(
@ -567,12 +630,19 @@ fn format_web_search_results(
items: Vec<WebSearchItem>,
limit: usize,
) -> Result<String, String> {
let results = items
let mut seen_links = HashSet::new();
let mut ranked = items
.into_iter()
.filter(|item| !item.title.trim().is_empty() && !item.link.trim().is_empty())
.filter(|item| seen_links.insert(item.link.trim().to_string()))
.filter_map(|item| web_search_relevance(query, &item).map(|score| (score, item)))
.collect::<Vec<_>>();
ranked.sort_by(|(left, _), (right, _)| right.cmp(left));
let results = ranked
.into_iter()
.take(limit)
.enumerate()
.map(|(index, item)| {
.map(|(index, (_, item))| {
format!(
"{}. {}\n 链接:{}\n 摘要:{}",
index + 1,
@ -583,7 +653,9 @@ fn format_web_search_results(
})
.collect::<Vec<_>>();
if results.is_empty() {
return Ok(format!("联网搜索“{query}”没有返回可用页面。"));
return Ok(format!(
"联网搜索“{query}”没有找到与查询词实际匹配的公开页面。搜索服务可能返回了泛化或无关候选,但这些候选已被过滤;不要把它们当作来源,也不要猜测链接。可改用更明确的公开主题或站点限定词再搜索。"
));
}
Ok(format!(
"联网搜索“{query}”得到以下页面。需要核实内容时,请继续调用 read_web_page 读取相关链接:\n{}",
@ -591,14 +663,86 @@ fn format_web_search_results(
))
}
fn web_search_relevance(query: &str, item: &WebSearchItem) -> Option<usize> {
let query_lower = query.to_lowercase();
let haystack = format!("{} {} {}", item.title, item.description, item.link).to_lowercase();
let ascii_tokens = regex::Regex::new(r"[a-z0-9][a-z0-9_-]+")
.expect("valid web-search token regex")
.find_iter(&query_lower)
.map(|value| value.as_str())
.filter(|token| !WEB_SEARCH_STOP_WORDS.contains(token))
.collect::<Vec<_>>();
let distinctive_ascii = ascii_tokens
.iter()
.copied()
.filter(|token| token.len() >= 5)
.collect::<Vec<_>>();
if !distinctive_ascii.is_empty()
&& !distinctive_ascii
.iter()
.any(|token| haystack.contains(token))
{
return None;
}
let mut score = ascii_tokens
.iter()
.filter(|token| haystack.contains(**token))
.map(|token| token.len().min(12))
.sum::<usize>();
if let Ok(url) = reqwest::Url::parse(&item.link) {
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
score += ascii_tokens
.iter()
.filter(|token| token.len() >= 4 && host.contains(**token))
.count()
* 20;
}
for run in regex::Regex::new(r"[\p{Han}]{2,}")
.expect("valid Han token regex")
.find_iter(&query_lower)
{
let chars = run.as_str().chars().collect::<Vec<_>>();
for pair in chars.windows(2) {
let token = pair.iter().collect::<String>();
if haystack.contains(&token) {
score += 2;
}
}
}
(score > 0).then_some(score)
}
const WEB_SEARCH_STOP_WORDS: &[&str] = &[
"about",
"current",
"find",
"latest",
"management",
"news",
"official",
"page",
"public",
"search",
"software",
"website",
"with",
];
fn read_web_page_from_tool_args(args: &serde_json::Value) -> Result<String, String> {
let raw_url = required_tool_string(args, READ_WEB_PAGE_TOOL_NAME, "url")?;
let url = reqwest::Url::parse(raw_url).map_err(|error| format!("无效网页地址:{error}"))?;
validate_public_https_url(&url)?;
let response = public_web_get(url.clone())
.map_err(|error| format!("读取网页失败:{error}"))?
.error_for_status()
.map_err(|error| format!("网页返回错误:{error}"))?;
let response = public_web_get(url.clone()).map_err(|error| format!("读取网页失败:{error}"))?;
if !response.status().is_success() {
let status = response.status();
let guidance = if matches!(status.as_u16(), 401 | 403 | 429) {
"该站点限制自动读取;这不表示搜索工具或模型 API 没有配置。请返回搜索结果并改读另一个相关公开来源,不要把本次读取失败说成整条联网链路失败。"
} else {
"请返回搜索结果并改读另一个相关公开来源。"
};
return Err(format!("网页返回 {status}{guidance}"));
}
let final_url = response.url().clone();
validate_public_https_url(&final_url)?;
let content_type = response
@ -1560,6 +1704,58 @@ mod tests {
assert!(result.contains("read_web_page"));
}
#[test]
fn web_search_filters_results_that_drop_the_distinctive_product_name() {
let items = vec![
WebSearchItem {
title: "个人知识库管理教程".into(),
link: "https://example.com/generic".into(),
description: "介绍 AI 知识管理方法".into(),
},
WebSearchItem {
title: "HoloLake Era release notes".into(),
link: "https://example.com/hololake".into(),
description: "HoloLake knowledge vault update".into(),
},
];
let result = format_web_search_results("HoloLake Era 知识库 AI", items, 6).unwrap();
assert!(result.contains("HoloLake Era release notes"));
assert!(!result.contains("个人知识库管理教程"));
}
#[test]
fn web_search_reports_no_match_instead_of_returning_unrelated_pages() {
let items = vec![WebSearchItem {
title: "贴吧发帖教程".into(),
link: "https://example.com/tieba".into(),
description: "一个完全不同的话题".into(),
}];
let result = format_web_search_results("HoloLake Era", items, 6).unwrap();
assert!(result.contains("没有找到与查询词实际匹配"));
assert!(!result.contains("https://example.com/tieba"));
}
#[test]
fn parses_360_results_without_exposing_redirect_links() {
let html = r#"
<ul class="result">
<li class="res-list"><h3 class="res-title"><a href="https://www.so.com/link?m=opaque" data-mdurl="https://openai.com/news/" target="_blank"><em>OpenAI</em> News</a></h3><p class="res-desc">Official <b>announcements</b></p></li>
<li class="res-list"><h3 class="res-title"><a href="https://www.so.com/link?m=opaque2" data-mdurl="http://internal.example/result" target="_blank">Insecure result</a></h3></li>
</ul>
"#;
let results = parse_360_web_search(html);
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "OpenAI News");
assert_eq!(results[0].link, "https://openai.com/news/");
assert_eq!(results[0].description, "Official announcements");
}
#[test]
fn public_web_reader_rejects_local_and_insecure_addresses() {
for url in [

View File

@ -90,7 +90,7 @@ Notes are Markdown files with YAML frontmatter. Organization is primarily expres
Prefer file edit tools for note changes.
Use magic_brush when the task needs knowledge-vault operations. Decide the temporary steps needed from vault orientation, search, read, create, edit, and delete; run them through magic_brush, then discard the temporary tool plan. Do not leave tool manifests or cache files behind. Delete only when the user clearly requests deletion. Vault Safe blocks terminal and out-of-vault access; it does not make the knowledge vault read-only.
When the user asks for current or external information without supplying a page, use search_web proactively. Read the relevant public pages with read_web_page before concluding; search snippets are discovery clues, not proof. Briefly cite the page links used. Web access does not grant login, private-network, account, purchase, posting, or other external-action authority.
When the user asks for current or external information without supplying a page, use search_web proactively. Search first and wait for its actual results; only in the following tool round may you pass one of those returned URLs to read_web_page. Never guess a result URL or batch a dependent read with the search that must discover it. Read relevant public pages before concluding; search snippets are discovery clues, not proof. If one site returns 401, 403, or 429, try another relevant result rather than declaring the search tool or model API unconfigured. If the user only asks to test search without naming a topic, use a stable public subject with an authoritative page instead of a local/private HoloLake name. Briefly cite the page links used. Web access does not grant login, private-network, account, purchase, posting, or other external-action authority.
If a temporary method proves reusable, you may propose packaging its instructions as a lightweight skill. A skill package stores trigger conditions, procedure, and boundaries; it is not a permanently mounted runtime tool. Create that package only after the user agrees. Future runs should load the skill on demand and let magic_brush recreate only the temporary operation needed for that run.