返回 CodeWhale
contract.rs
根目录 / crates / tui / src / tools / web / contract.rs
1 //! Provider-neutral web-search request, result, and receipt types.
2 //!
3 //! Search backends vary widely in what they accept and return. These types are
4 //! the model-visible honesty boundary shared by `web_search` and `web.run`:
5 //! callers can distinguish requested knobs, actually honored behavior, and
6 //! degraded/post-filtered execution without depending on provider payloads.
7
8 use serde::{Deserialize, Serialize};
9
10 pub(crate) const MAX_SEARCH_RESULTS: u8 = 10;
11
12 /// Shared retrieval defaults. `web_search` and `web.run` both derive their
13 /// search knobs from these so the surfaces cannot drift apart.
14 pub(crate) const DEFAULT_SEARCH_RESULTS: usize = 5;
15 pub(crate) const DEFAULT_SEARCH_TIMEOUT_MS: u64 = 15_000;
16 pub(crate) const MAX_SEARCH_TIMEOUT_MS: u64 = 60_000;
17
18 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19 #[serde(rename_all = "snake_case")]
20 pub(crate) enum BackendId {
21 ProviderNative,
22 Bing,
23 #[serde(rename = "duckduckgo")]
24 DuckDuckGo,
25 Tavily,
26 Bocha,
27 Metaso,
28 Searxng,
29 Baidu,
30 Volcengine,
31 Sofya,
32 }
33
34 impl BackendId {
35 #[must_use]
36 pub(crate) const fn as_str(self) -> &'static str {
37 match self {
38 Self::ProviderNative => "provider_native",
39 Self::Bing => "bing",
40 Self::DuckDuckGo => "duckduckgo",
41 Self::Tavily => "tavily",
42 Self::Bocha => "bocha",
43 Self::Metaso => "metaso",
44 Self::Searxng => "searxng",
45 Self::Baidu => "baidu",
46 Self::Volcengine => "volcengine",
47 Self::Sofya => "sofya",
48 }
49 }
50 }
51
52 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53 #[serde(rename_all = "snake_case")]
54 pub(crate) enum Recency {
55 Day,
56 Week,
57 Month,
58 Year,
59 Days(u16),
60 }
61
62 impl Recency {
63 #[must_use]
64 #[cfg(test)]
65 pub(crate) const fn days(self) -> u16 {
66 match self {
67 Self::Day => 1,
68 Self::Week => 7,
69 Self::Month => 30,
70 Self::Year => 365,
71 Self::Days(days) => days,
72 }
73 }
74 }
75
76 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
77 pub(crate) struct SearchQuery {
78 pub(crate) query: String,
79 pub(crate) max_results: u8,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub(crate) recency: Option<Recency>,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 pub(crate) domains: Vec<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub(crate) locale: Option<String>,
86 }
87
88 impl SearchQuery {
89 #[must_use]
90 pub(crate) fn new(
91 query: String,
92 max_results: usize,
93 recency: Option<Recency>,
94 domains: Vec<String>,
95 locale: Option<String>,
96 ) -> Self {
97 let mut domains = domains
98 .into_iter()
99 .map(|domain| {
100 let domain = domain.trim().trim_end_matches('.').to_ascii_lowercase();
101 domain.trim_start_matches("www.").to_string()
102 })
103 .filter(|domain| !domain.is_empty())
104 .collect::<Vec<_>>();
105 domains.sort_unstable();
106 domains.dedup();
107 Self {
108 query,
109 max_results: u8::try_from(max_results.clamp(1, usize::from(MAX_SEARCH_RESULTS)))
110 .unwrap_or(MAX_SEARCH_RESULTS),
111 recency,
112 domains,
113 locale: locale
114 .map(|value| value.trim().to_string())
115 .filter(|value| !value.is_empty()),
116 }
117 }
118 }
119
120 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121 #[serde(rename_all = "snake_case")]
122 pub(crate) enum CapabilityState {
123 Supported,
124 Unsupported,
125 Unknown,
126 }
127
128 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129 pub(crate) struct QueryCapabilities {
130 pub(crate) max_results: CapabilityState,
131 pub(crate) recency: CapabilityState,
132 pub(crate) domains: CapabilityState,
133 pub(crate) locale: CapabilityState,
134 pub(crate) published_date: CapabilityState,
135 }
136
137 impl QueryCapabilities {
138 #[must_use]
139 pub(crate) const fn count_only() -> Self {
140 Self {
141 max_results: CapabilityState::Supported,
142 recency: CapabilityState::Unsupported,
143 domains: CapabilityState::Unsupported,
144 locale: CapabilityState::Unsupported,
145 published_date: CapabilityState::Unknown,
146 }
147 }
148 }
149
150 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
151 pub(crate) struct HonoredQueryCapabilities {
152 pub(crate) max_results: bool,
153 pub(crate) recency: bool,
154 pub(crate) domains: bool,
155 pub(crate) locale: bool,
156 }
157
158 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159 #[serde(rename_all = "snake_case")]
160 pub(crate) enum QueryKnob {
161 Recency,
162 Domains,
163 Locale,
164 }
165
166 impl QueryKnob {
167 const fn as_str(self) -> &'static str {
168 match self {
169 Self::Recency => "recency",
170 Self::Domains => "domains",
171 Self::Locale => "locale",
172 }
173 }
174 }
175
176 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177 #[serde(tag = "kind", rename_all = "snake_case")]
178 pub(crate) enum DegradedReason {
179 BackendUnavailable { backend: BackendId },
180 NoUsableResults { backend: BackendId },
181 BackendFallback { from: BackendId, to: BackendId },
182 ChallengeDetected { backend: BackendId },
183 ScrapeFallback { from: BackendId, to: BackendId },
184 KnobIgnored { knob: QueryKnob },
185 PostFiltered { knob: QueryKnob },
186 SynthesizedResults,
187 }
188
189 impl DegradedReason {
190 #[must_use]
191 pub(crate) fn message(&self) -> String {
192 match self {
193 Self::BackendUnavailable { backend } => {
194 format!("{} was unavailable", backend.as_str())
195 }
196 Self::NoUsableResults { backend } => {
197 format!("{} returned no usable results", backend.as_str())
198 }
199 Self::BackendFallback { from, to } => format!(
200 "{} did not answer; tried {} next",
201 from.as_str(),
202 to.as_str()
203 ),
204 Self::ChallengeDetected { backend } => {
205 format!("{} returned a bot challenge", backend.as_str())
206 }
207 Self::ScrapeFallback { from, to } => format!(
208 "{} returned no usable results; used {} fallback",
209 from.as_str(),
210 to.as_str()
211 ),
212 Self::KnobIgnored { knob } => {
213 format!("{} filter was not enforced by this backend", knob.as_str())
214 }
215 Self::PostFiltered { knob } => {
216 format!("results were post-filtered by {}", knob.as_str())
217 }
218 Self::SynthesizedResults => {
219 "results were synthesized by a model-backed search response".to_string()
220 }
221 }
222 }
223 }
224
225 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226 pub(crate) struct SearchResult {
227 pub(crate) rank: u8,
228 /// Session-scoped citation handle. Backends leave this empty; the shared
229 /// execution surface mints it before any result crosses the tool boundary.
230 pub(crate) ref_id: String,
231 pub(crate) title: String,
232 pub(crate) url: String,
233 #[serde(skip_serializing_if = "Option::is_none")]
234 pub(crate) snippet: Option<String>,
235 #[serde(skip_serializing_if = "Option::is_none")]
236 pub(crate) published: Option<String>,
237 pub(crate) domain: String,
238 }
239
240 impl SearchResult {
241 #[must_use]
242 pub(crate) fn new(
243 rank: usize,
244 title: String,
245 url: String,
246 snippet: Option<String>,
247 published: Option<String>,
248 ) -> Self {
249 let domain = reqwest::Url::parse(&url)
250 .ok()
251 .and_then(|parsed| parsed.host_str().map(str::to_ascii_lowercase))
252 .unwrap_or_default();
253 Self {
254 rank: u8::try_from(rank.clamp(1, 255)).unwrap_or(u8::MAX),
255 ref_id: String::new(),
256 title,
257 url,
258 snippet,
259 published,
260 domain,
261 }
262 }
263 }
264
265 #[derive(Debug, Clone)]
266 pub(crate) struct BackendSearch {
267 pub(crate) backend: BackendId,
268 pub(crate) source: String,
269 pub(crate) backend_detail: Option<String>,
270 pub(crate) results: Vec<SearchResult>,
271 pub(crate) degraded: Vec<DegradedReason>,
272 pub(crate) note: Option<String>,
273 }
274
275 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276 pub(crate) struct SearchReceipt {
277 pub(crate) backend: BackendId,
278 #[serde(skip_serializing_if = "Option::is_none")]
279 pub(crate) backend_detail: Option<String>,
280 pub(crate) requested: SearchQuery,
281 pub(crate) capabilities: QueryCapabilities,
282 pub(crate) honored: HonoredQueryCapabilities,
283 #[serde(default, skip_serializing_if = "Vec::is_empty")]
284 pub(crate) degraded: Vec<DegradedReason>,
285 pub(crate) latency_ms: u32,
286 pub(crate) cache_hit: bool,
287 }
288
289 impl SearchReceipt {
290 #[must_use]
291 pub(crate) fn warning(&self) -> Option<String> {
292 if self.degraded.is_empty() {
293 None
294 } else {
295 Some(
296 self.degraded
297 .iter()
298 .map(DegradedReason::message)
299 .collect::<Vec<_>>()
300 .join("; "),
301 )
302 }
303 }
304 }
305
306 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307 pub(crate) struct SearchResponse {
308 pub(crate) query: String,
309 pub(crate) source: String,
310 pub(crate) count: usize,
311 pub(crate) message: String,
312 pub(crate) results: Vec<SearchResult>,
313 pub(crate) receipt: SearchReceipt,
314 }
315
316 #[cfg(test)]
317 mod tests {
318 use super::*;
319
320 #[test]
321 fn retrieval_defaults_are_coherent_across_search_and_fetch() {
322 use super::super::fetch::{DEFAULT_TIMEOUT, HARD_MAX_TIMEOUT};
323
324 // Search and fetch share one default and one hard-cap timeout so the
325 // two halves of the retrieval path behave identically by default.
326 assert_eq!(
327 u128::from(DEFAULT_SEARCH_TIMEOUT_MS),
328 DEFAULT_TIMEOUT.as_millis()
329 );
330 assert_eq!(
331 u128::from(MAX_SEARCH_TIMEOUT_MS),
332 HARD_MAX_TIMEOUT.as_millis()
333 );
334 assert!(DEFAULT_SEARCH_RESULTS <= usize::from(MAX_SEARCH_RESULTS));
335 }
336
337 #[test]
338 fn search_query_normalizes_domains_and_bounds_count() {
339 let query = SearchQuery::new(
340 "rust async".to_string(),
341 99,
342 Some(Recency::Week),
343 vec![" WWW.Example.COM. ".to_string(), "example.com".to_string()],
344 Some(" en-US ".to_string()),
345 );
346
347 assert_eq!(query.max_results, 10);
348 assert_eq!(query.domains, vec!["example.com"]);
349 assert_eq!(query.locale.as_deref(), Some("en-US"));
350 assert_eq!(query.recency.map(Recency::days), Some(7));
351 }
352
353 #[test]
354 fn normalized_result_derives_domain_and_rank() {
355 let result = SearchResult::new(
356 2,
357 "Example".to_string(),
358 "https://Docs.Example.com/path".to_string(),
359 Some("summary".to_string()),
360 None,
361 );
362
363 assert_eq!(result.rank, 2);
364 assert_eq!(result.domain, "docs.example.com");
365 }
366
367 #[test]
368 fn degraded_receipt_is_machine_readable_and_human_visible() {
369 let receipt = SearchReceipt {
370 backend: BackendId::DuckDuckGo,
371 backend_detail: None,
372 requested: SearchQuery::new(
373 "fresh result".to_string(),
374 5,
375 Some(Recency::Day),
376 Vec::new(),
377 None,
378 ),
379 capabilities: QueryCapabilities::count_only(),
380 honored: HonoredQueryCapabilities {
381 max_results: true,
382 ..HonoredQueryCapabilities::default()
383 },
384 degraded: vec![DegradedReason::KnobIgnored {
385 knob: QueryKnob::Recency,
386 }],
387 latency_ms: 4,
388 cache_hit: false,
389 };
390
391 let value = serde_json::to_value(&receipt).expect("receipt serializes");
392 assert_eq!(value["backend"], "duckduckgo");
393 assert_eq!(value["degraded"][0]["kind"], "knob_ignored");
394 assert!(receipt.warning().expect("warning").contains("recency"));
395 }
396
397 #[test]
398 fn scrape_fallback_receipt_preserves_backend_transitions() {
399 let receipt = SearchReceipt {
400 backend: BackendId::Bing,
401 backend_detail: None,
402 requested: SearchQuery::new("fallback query".to_string(), 5, None, Vec::new(), None),
403 capabilities: QueryCapabilities::count_only(),
404 honored: HonoredQueryCapabilities {
405 max_results: true,
406 ..HonoredQueryCapabilities::default()
407 },
408 degraded: vec![
409 DegradedReason::ChallengeDetected {
410 backend: BackendId::DuckDuckGo,
411 },
412 DegradedReason::ScrapeFallback {
413 from: BackendId::DuckDuckGo,
414 to: BackendId::Bing,
415 },
416 ],
417 latency_ms: 12,
418 cache_hit: false,
419 };
420
421 let value = serde_json::to_value(&receipt).expect("receipt serializes");
422 assert_eq!(value["backend"], "bing");
423 assert_eq!(value["degraded"][0]["kind"], "challenge_detected");
424 assert_eq!(value["degraded"][0]["backend"], "duckduckgo");
425 assert_eq!(value["degraded"][1]["kind"], "scrape_fallback");
426 assert_eq!(value["degraded"][1]["from"], "duckduckgo");
427 assert_eq!(value["degraded"][1]["to"], "bing");
428 let warning = receipt.warning().expect("warning");
429 assert!(warning.contains("bot challenge"));
430 assert!(warning.contains("used bing fallback"));
431 }
432 }
433
433 lines RUST