1use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
4use validator::Validate;
5
6use crate::{ZaiResult, client::ZaiClient};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum KnowledgeRecallMethod {
12 Embedding,
14 Keyword,
16 Mixed,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22pub enum KnowledgeRerankModel {
23 #[serde(rename = "rerank")]
25 Rerank,
26 #[serde(rename = "rerank-pro")]
28 RerankPro,
29}
30
31#[derive(Clone, Serialize, Deserialize, Validate)]
33pub struct KnowledgeSearchBody {
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub request_id: Option<String>,
37 #[validate(length(min = 1, max = 1000))]
39 pub query: String,
40 #[validate(length(min = 1))]
42 pub knowledge_ids: Vec<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub document_ids: Option<Vec<String>>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 #[validate(range(min = 1, max = 20))]
49 pub top_k: Option<u32>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 #[validate(range(min = 1, max = 100))]
53 pub top_n: Option<u32>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub recall_method: Option<KnowledgeRecallMethod>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 #[validate(range(min = 1, max = 99))]
60 pub recall_ratio: Option<u8>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 #[validate(range(max = 1))]
64 pub rerank_status: Option<u8>,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub rerank_model: Option<KnowledgeRerankModel>,
68 #[serde(
73 rename = "fractional_threshold",
74 skip_serializing_if = "Option::is_none"
75 )]
76 pub score_threshold: Option<f64>,
77}
78
79impl std::fmt::Debug for KnowledgeSearchBody {
80 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 formatter
82 .debug_struct("KnowledgeSearchBody")
83 .field(
84 "request_id",
85 &self.request_id.as_ref().map(|_| "[REDACTED]"),
86 )
87 .field("query", &"[REDACTED]")
88 .field("knowledge_id_count", &self.knowledge_ids.len())
89 .field(
90 "document_id_count",
91 &self.document_ids.as_ref().map(Vec::len),
92 )
93 .field("top_k", &self.top_k)
94 .field("top_n", &self.top_n)
95 .field("recall_method", &self.recall_method)
96 .field("recall_ratio", &self.recall_ratio)
97 .field("rerank_status", &self.rerank_status)
98 .field("rerank_model", &self.rerank_model)
99 .field("score_threshold", &self.score_threshold)
100 .finish()
101 }
102}
103
104pub struct KnowledgeSearchRequest {
106 pub body: KnowledgeSearchBody,
108}
109
110impl KnowledgeSearchRequest {
111 pub fn new(knowledge_id: impl Into<String>, query: impl Into<String>) -> Self {
113 Self {
114 body: KnowledgeSearchBody {
115 request_id: None,
116 query: query.into(),
117 knowledge_ids: vec![knowledge_id.into()],
118 document_ids: None,
119 top_k: None,
120 top_n: None,
121 recall_method: None,
122 recall_ratio: None,
123 rerank_status: None,
124 rerank_model: None,
125 score_threshold: None,
126 },
127 }
128 }
129
130 pub fn with_knowledge_ids(mut self, knowledge_ids: Vec<String>) -> Self {
132 self.body.knowledge_ids = knowledge_ids;
133 self
134 }
135
136 pub fn with_document_ids(mut self, document_ids: Vec<String>) -> Self {
138 self.body.document_ids = Some(document_ids);
139 self
140 }
141
142 pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
144 self.body.request_id = Some(request_id.into());
145 self
146 }
147
148 pub fn with_top_k(mut self, top_k: u32) -> Self {
150 self.body.top_k = Some(top_k);
151 self
152 }
153
154 pub fn with_top_n(mut self, top_n: u32) -> Self {
156 self.body.top_n = Some(top_n);
157 self
158 }
159
160 pub fn with_recall_method(mut self, method: KnowledgeRecallMethod) -> Self {
162 self.body.recall_method = Some(method);
163 self
164 }
165
166 pub fn with_recall_ratio(mut self, ratio: u8) -> Self {
168 self.body.recall_ratio = Some(ratio);
169 self
170 }
171
172 pub fn with_reranking(mut self, enabled: bool, model: KnowledgeRerankModel) -> Self {
174 self.body.rerank_status = Some(u8::from(enabled));
175 self.body.rerank_model = Some(model);
176 self
177 }
178
179 pub fn with_score_threshold(mut self, threshold: f64) -> Self {
181 self.body.score_threshold = Some(threshold);
182 self
183 }
184
185 pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<KnowledgeSearchResponse> {
187 self.body.validate()?;
188 if self.body.query.trim().is_empty() {
189 return Err(crate::client::validation::invalid("query cannot be blank"));
190 }
191 if self
192 .body
193 .knowledge_ids
194 .iter()
195 .any(|knowledge_id| knowledge_id.trim().is_empty())
196 {
197 return Err(crate::client::validation::invalid(
198 "knowledge_ids cannot contain blank values",
199 ));
200 }
201 if self
202 .body
203 .document_ids
204 .as_ref()
205 .is_some_and(|ids| ids.is_empty() || ids.iter().any(|id| id.trim().is_empty()))
206 {
207 return Err(crate::client::validation::invalid(
208 "document_ids must contain at least one non-blank value",
209 ));
210 }
211 if self
212 .body
213 .score_threshold
214 .is_some_and(|threshold| !threshold.is_finite() || threshold <= 0.0 || threshold >= 1.0)
215 {
216 return Err(crate::client::validation::invalid(
217 "score_threshold must be finite and in the range 0.0 < value < 1.0",
218 ));
219 }
220
221 let route = crate::client::routes::KNOWLEDGE_RETRIEVE;
222 let url = client.endpoints().resolve_route(route, &[])?;
223 client
224 .send_json::<_, KnowledgeSearchResponse>(route.method(), url, &self.body)
225 .await
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct KnowledgeSearchMetadata {
232 #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
234 pub id: Option<String>,
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub knowledge_id: Option<String>,
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub doc_id: Option<String>,
241 #[serde(skip_serializing_if = "Option::is_none")]
243 pub doc_name: Option<String>,
244 #[serde(skip_serializing_if = "Option::is_none")]
246 pub doc_url: Option<String>,
247 #[serde(skip_serializing_if = "Option::is_none")]
249 pub contextual_text: Option<String>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct KnowledgeSearchResult {
255 #[serde(skip_serializing_if = "Option::is_none")]
257 pub text: Option<String>,
258 #[serde(skip_serializing_if = "Option::is_none")]
260 pub score: Option<f64>,
261 #[serde(skip_serializing_if = "Option::is_none")]
263 pub metadata: Option<KnowledgeSearchMetadata>,
264}
265
266#[derive(Debug, Clone, Serialize)]
268pub struct KnowledgeSearchResponse {
269 #[serde(skip_serializing_if = "Option::is_none")]
271 pub data: Option<Vec<KnowledgeSearchResult>>,
272 #[serde(skip_serializing_if = "Option::is_none")]
275 pub code: Option<i64>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub message: Option<String>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub timestamp: Option<u64>,
282}
283
284#[derive(Deserialize)]
285struct KnowledgeSearchResponseWire {
286 data: Option<Vec<KnowledgeSearchResult>>,
287 code: Option<i64>,
288 message: Option<String>,
289 timestamp: Option<u64>,
290}
291
292impl<'de> Deserialize<'de> for KnowledgeSearchResponse {
293 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
294 where
295 D: Deserializer<'de>,
296 {
297 let wire = KnowledgeSearchResponseWire::deserialize(deserializer)?;
298 if wire.data.is_none()
299 && wire.code.is_none()
300 && wire.message.is_none()
301 && wire.timestamp.is_none()
302 {
303 return Err(D::Error::custom(
304 "knowledge-search response contained no documented non-null fields",
305 ));
306 }
307 Ok(Self {
308 data: wire.data,
309 code: wire.code,
310 message: wire.message,
311 timestamp: wire.timestamp,
312 })
313 }
314}
315
316impl KnowledgeSearchResponse {
317 pub fn results(&self) -> Option<&[KnowledgeSearchResult]> {
319 self.data.as_deref()
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn convenience_builder_matches_official_wire_names() {
329 let request = KnowledgeSearchRequest::new("kb-1", "question")
330 .with_top_k(5)
331 .with_score_threshold(0.25);
332 let value = serde_json::to_value(&request.body).unwrap();
333
334 assert_eq!(value["knowledge_ids"], serde_json::json!(["kb-1"]));
335 assert_eq!(value["fractional_threshold"], 0.25);
336 assert!(value.get("knowledge_id").is_none());
337 assert!(value.get("score_threshold").is_none());
338 }
339
340 #[test]
341 fn request_body_debug_redacts_query_and_identifiers() {
342 let body = KnowledgeSearchRequest::new("private-knowledge", "private query")
343 .with_document_ids(vec!["private-document".to_owned()])
344 .with_request_id("private-request")
345 .body;
346 let debug = format!("{body:?}");
347 for secret in [
348 "private-knowledge",
349 "private query",
350 "private-document",
351 "private-request",
352 ] {
353 assert!(!debug.contains(secret));
354 }
355 assert!(debug.contains("knowledge_id_count: 1"));
356 assert!(debug.contains("document_id_count: Some(1)"));
357 }
358
359 #[test]
360 fn response_uses_typed_metadata_and_follows_optional_frozen_fields() {
361 let response: KnowledgeSearchResponse = serde_json::from_value(serde_json::json!({
362 "code": 200,
363 "data": [{
364 "text": "chunk",
365 "score": 0.9,
366 "metadata": {
367 "_id": "slice-1",
368 "knowledge_id": "kb-1",
369 "doc_id": "doc-1",
370 "doc_name": "guide.md",
371 "doc_url": "https://example.com/guide.md",
372 "contextual_text": "context"
373 }
374 }]
375 }))
376 .unwrap();
377 let result = &response.results().unwrap()[0];
378 assert_eq!(result.text.as_deref(), Some("chunk"));
379 assert_eq!(result.score, Some(0.9));
380 assert_eq!(
381 result
382 .metadata
383 .as_ref()
384 .and_then(|metadata| metadata.id.as_deref()),
385 Some("slice-1")
386 );
387
388 assert!(
389 serde_json::from_value::<KnowledgeSearchResponse>(serde_json::json!({"data": []}))
390 .is_ok()
391 );
392 assert!(
393 serde_json::from_value::<KnowledgeSearchResponse>(serde_json::json!({"code": 200}))
394 .is_ok()
395 );
396 assert!(serde_json::from_value::<KnowledgeSearchResponse>(serde_json::json!({})).is_err());
397 }
398}