Skip to main content

vectorizer_sdk/client/
discovery.rs

1//! Discovery surface: orchestrated multi-stage retrieval.
2//!
3//! `discover` is the headline pipeline (filter → score → expand →
4//! search → bullet-summarise); the other methods expose individual
5//! stages and the new phase12 pipeline steps:
6//! - `broad_discovery` — multi-query broad search across collections
7//! - `semantic_focus` — focused search within one collection
8//! - `promote_readme` — README-quality chunk promotion
9//! - `compress_evidence` — evidence compression into bullets
10//! - `build_answer_plan` — bullet → section organisation
11//! - `render_llm_prompt` — plan → final LLM prompt string
12
13use super::VectorizerClient;
14use crate::error::{Result, VectorizerError};
15use crate::models::{
16    AnswerPlan, AnswerPlanRequest, BroadDiscoveryRequest, BroadDiscoveryResponse,
17    CompressEvidenceRequest, CompressEvidenceResponse, LlmPrompt, PromoteReadmeRequest,
18    PromoteReadmeResponse, RenderPromptRequest, SemanticFocusRequest, SemanticFocusResponse,
19};
20
21impl VectorizerClient {
22    /// End-to-end discovery pipeline with intelligent search and
23    /// LLM-style bullet generation.
24    #[allow(clippy::too_many_arguments)]
25    pub async fn discover(
26        &self,
27        query: &str,
28        include_collections: Option<Vec<String>>,
29        exclude_collections: Option<Vec<String>>,
30        max_bullets: Option<usize>,
31        broad_k: Option<usize>,
32        focus_k: Option<usize>,
33    ) -> Result<serde_json::Value> {
34        if query.trim().is_empty() {
35            return Err(VectorizerError::validation("Query cannot be empty"));
36        }
37        if let Some(max) = max_bullets
38            && max == 0
39        {
40            return Err(VectorizerError::validation(
41                "max_bullets must be greater than 0",
42            ));
43        }
44
45        let mut payload = serde_json::Map::new();
46        payload.insert(
47            "query".to_string(),
48            serde_json::Value::String(query.to_string()),
49        );
50        if let Some(inc) = include_collections {
51            payload.insert(
52                "include_collections".to_string(),
53                serde_json::to_value(inc).unwrap(),
54            );
55        }
56        if let Some(exc) = exclude_collections {
57            payload.insert(
58                "exclude_collections".to_string(),
59                serde_json::to_value(exc).unwrap(),
60            );
61        }
62        if let Some(max) = max_bullets {
63            payload.insert(
64                "max_bullets".to_string(),
65                serde_json::Value::Number(max.into()),
66            );
67        }
68        if let Some(k) = broad_k {
69            payload.insert("broad_k".to_string(), serde_json::Value::Number(k.into()));
70        }
71        if let Some(k) = focus_k {
72            payload.insert("focus_k".to_string(), serde_json::Value::Number(k.into()));
73        }
74
75        let response = self
76            .make_request(
77                "POST",
78                "/discover",
79                Some(serde_json::Value::Object(payload)),
80            )
81            .await?;
82        serde_json::from_str(&response)
83            .map_err(|e| VectorizerError::server(format!("Failed to parse discover response: {e}")))
84    }
85
86    /// Pre-filter collections by name patterns.
87    pub async fn filter_collections(
88        &self,
89        query: &str,
90        include: Option<Vec<String>>,
91        exclude: Option<Vec<String>>,
92    ) -> Result<serde_json::Value> {
93        if query.trim().is_empty() {
94            return Err(VectorizerError::validation("Query cannot be empty"));
95        }
96        let mut payload = serde_json::Map::new();
97        payload.insert(
98            "query".to_string(),
99            serde_json::Value::String(query.to_string()),
100        );
101        if let Some(inc) = include {
102            payload.insert("include".to_string(), serde_json::to_value(inc).unwrap());
103        }
104        if let Some(exc) = exclude {
105            payload.insert("exclude".to_string(), serde_json::to_value(exc).unwrap());
106        }
107        let response = self
108            .make_request(
109                "POST",
110                "/discovery/filter_collections",
111                Some(serde_json::Value::Object(payload)),
112            )
113            .await?;
114        serde_json::from_str(&response)
115            .map_err(|e| VectorizerError::server(format!("Failed to parse filter response: {e}")))
116    }
117
118    /// Rank collections by relevance to a query. The three weights
119    /// must each be in `[0.0, 1.0]` when supplied.
120    pub async fn score_collections(
121        &self,
122        query: &str,
123        name_match_weight: Option<f32>,
124        term_boost_weight: Option<f32>,
125        signal_boost_weight: Option<f32>,
126    ) -> Result<serde_json::Value> {
127        if let Some(w) = name_match_weight
128            && !(0.0..=1.0).contains(&w)
129        {
130            return Err(VectorizerError::validation(
131                "name_match_weight must be between 0.0 and 1.0",
132            ));
133        }
134        if let Some(w) = term_boost_weight
135            && !(0.0..=1.0).contains(&w)
136        {
137            return Err(VectorizerError::validation(
138                "term_boost_weight must be between 0.0 and 1.0",
139            ));
140        }
141        if let Some(w) = signal_boost_weight
142            && !(0.0..=1.0).contains(&w)
143        {
144            return Err(VectorizerError::validation(
145                "signal_boost_weight must be between 0.0 and 1.0",
146            ));
147        }
148
149        let mut payload = serde_json::Map::new();
150        payload.insert(
151            "query".to_string(),
152            serde_json::Value::String(query.to_string()),
153        );
154        if let Some(w) = name_match_weight {
155            payload.insert("name_match_weight".to_string(), serde_json::json!(w));
156        }
157        if let Some(w) = term_boost_weight {
158            payload.insert("term_boost_weight".to_string(), serde_json::json!(w));
159        }
160        if let Some(w) = signal_boost_weight {
161            payload.insert("signal_boost_weight".to_string(), serde_json::json!(w));
162        }
163        let response = self
164            .make_request(
165                "POST",
166                "/discovery/score_collections",
167                Some(serde_json::Value::Object(payload)),
168            )
169            .await?;
170        serde_json::from_str(&response)
171            .map_err(|e| VectorizerError::server(format!("Failed to parse score response: {e}")))
172    }
173
174    /// Generate query variations (definition / features /
175    /// architecture-style expansions, capped by `max_expansions`).
176    pub async fn expand_queries(
177        &self,
178        query: &str,
179        max_expansions: Option<usize>,
180        include_definition: Option<bool>,
181        include_features: Option<bool>,
182        include_architecture: Option<bool>,
183    ) -> Result<serde_json::Value> {
184        let mut payload = serde_json::Map::new();
185        payload.insert(
186            "query".to_string(),
187            serde_json::Value::String(query.to_string()),
188        );
189        if let Some(max) = max_expansions {
190            payload.insert(
191                "max_expansions".to_string(),
192                serde_json::Value::Number(max.into()),
193            );
194        }
195        if let Some(def) = include_definition {
196            payload.insert(
197                "include_definition".to_string(),
198                serde_json::Value::Bool(def),
199            );
200        }
201        if let Some(feat) = include_features {
202            payload.insert(
203                "include_features".to_string(),
204                serde_json::Value::Bool(feat),
205            );
206        }
207        if let Some(arch) = include_architecture {
208            payload.insert(
209                "include_architecture".to_string(),
210                serde_json::Value::Bool(arch),
211            );
212        }
213        let response = self
214            .make_request(
215                "POST",
216                "/discovery/expand_queries",
217                Some(serde_json::Value::Object(payload)),
218            )
219            .await?;
220        serde_json::from_str(&response)
221            .map_err(|e| VectorizerError::server(format!("Failed to parse expand response: {e}")))
222    }
223
224    /// Broad multi-query search across all collections.
225    ///
226    /// Calls `POST /discovery/broad_discovery` with `{queries, k?}`.
227    pub async fn broad_discovery(
228        &self,
229        request: BroadDiscoveryRequest,
230    ) -> Result<BroadDiscoveryResponse> {
231        let payload = serde_json::json!({
232            "queries": request.queries,
233            "k": request.k.unwrap_or(50),
234        });
235        let response = self
236            .make_request("POST", "/discovery/broad_discovery", Some(payload))
237            .await?;
238        serde_json::from_str(&response).map_err(|e| {
239            VectorizerError::server(format!("Failed to parse broad_discovery response: {e}"))
240        })
241    }
242
243    /// Focused semantic search within a single collection.
244    ///
245    /// Calls `POST /discovery/semantic_focus` with `{collection, queries, k?}`.
246    pub async fn semantic_focus(
247        &self,
248        request: SemanticFocusRequest,
249    ) -> Result<SemanticFocusResponse> {
250        let payload = serde_json::json!({
251            "collection": request.collection,
252            "queries": request.queries,
253            "k": request.k.unwrap_or(15),
254        });
255        let response = self
256            .make_request("POST", "/discovery/semantic_focus", Some(payload))
257            .await?;
258        serde_json::from_str(&response).map_err(|e| {
259            VectorizerError::server(format!("Failed to parse semantic_focus response: {e}"))
260        })
261    }
262
263    /// Promote README-quality chunks to the top of a result set.
264    ///
265    /// Calls `POST /discovery/promote_readme` with `{chunks}`.
266    pub async fn promote_readme(
267        &self,
268        request: PromoteReadmeRequest,
269    ) -> Result<PromoteReadmeResponse> {
270        let payload = serde_json::json!({ "chunks": request.chunks });
271        let response = self
272            .make_request("POST", "/discovery/promote_readme", Some(payload))
273            .await?;
274        serde_json::from_str(&response).map_err(|e| {
275            VectorizerError::server(format!("Failed to parse promote_readme response: {e}"))
276        })
277    }
278
279    /// Compress a chunk set into a concise bullet list.
280    ///
281    /// Calls `POST /discovery/compress_evidence` with
282    /// `{chunks, max_bullets?, max_per_doc?}`.
283    pub async fn compress_evidence(
284        &self,
285        request: CompressEvidenceRequest,
286    ) -> Result<CompressEvidenceResponse> {
287        let mut payload = serde_json::json!({ "chunks": request.chunks });
288        if let Some(mb) = request.max_bullets {
289            payload["max_bullets"] = serde_json::json!(mb);
290        }
291        if let Some(mpd) = request.max_per_doc {
292            payload["max_per_doc"] = serde_json::json!(mpd);
293        }
294        let response = self
295            .make_request("POST", "/discovery/compress_evidence", Some(payload))
296            .await?;
297        serde_json::from_str(&response).map_err(|e| {
298            VectorizerError::server(format!("Failed to parse compress_evidence response: {e}"))
299        })
300    }
301
302    /// Organise bullets into a structured answer plan.
303    ///
304    /// Calls `POST /discovery/build_answer_plan` with `{bullets}`.
305    pub async fn build_answer_plan(&self, request: AnswerPlanRequest) -> Result<AnswerPlan> {
306        let payload = serde_json::json!({ "bullets": request.bullets });
307        let response = self
308            .make_request("POST", "/discovery/build_answer_plan", Some(payload))
309            .await?;
310        serde_json::from_str(&response).map_err(|e| {
311            VectorizerError::server(format!("Failed to parse build_answer_plan response: {e}"))
312        })
313    }
314
315    /// Render an answer plan into a final LLM prompt string.
316    ///
317    /// Calls `POST /discovery/render_llm_prompt` with `{plan}`.
318    pub async fn render_llm_prompt(&self, request: RenderPromptRequest) -> Result<LlmPrompt> {
319        let payload = serde_json::json!({ "plan": request.plan });
320        let response = self
321            .make_request("POST", "/discovery/render_llm_prompt", Some(payload))
322            .await?;
323        serde_json::from_str(&response).map_err(|e| {
324            VectorizerError::server(format!("Failed to parse render_llm_prompt response: {e}"))
325        })
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    #![allow(clippy::unwrap_used)]
332
333    use serde_json::json;
334
335    use crate::models::{
336        AnswerPlan, AnswerPlanRequest, BroadDiscoveryRequest, BroadDiscoveryResponse,
337        CompressEvidenceRequest, CompressEvidenceResponse, LlmPrompt, PromoteReadmeRequest,
338        PromoteReadmeResponse, RenderPromptRequest, SemanticFocusRequest, SemanticFocusResponse,
339    };
340
341    #[test]
342    fn broad_discovery_request_serializes() {
343        let req = BroadDiscoveryRequest {
344            queries: vec!["HNSW index".into(), "embedding model".into()],
345            k: Some(30),
346        };
347        let v = serde_json::to_value(&req).unwrap();
348        assert_eq!(v["queries"][0], "HNSW index");
349        assert_eq!(v["k"], 30);
350    }
351
352    #[test]
353    fn broad_discovery_response_deserializes() {
354        let raw = json!({
355            "chunks": [{"collection": "docs", "score": 0.9, "content_preview": "test"}],
356            "count": 1
357        });
358        let resp: BroadDiscoveryResponse = serde_json::from_value(raw).unwrap();
359        assert_eq!(resp.count, 1);
360        assert_eq!(resp.chunks.len(), 1);
361    }
362
363    #[test]
364    fn semantic_focus_request_serializes() {
365        let req = SemanticFocusRequest {
366            collection: "code".into(),
367            queries: vec!["async runtime".into()],
368            k: None,
369        };
370        let v = serde_json::to_value(&req).unwrap();
371        assert_eq!(v["collection"], "code");
372        assert_eq!(v["queries"][0], "async runtime");
373    }
374
375    #[test]
376    fn semantic_focus_response_deserializes() {
377        let raw = json!({ "chunks": [], "count": 0 });
378        let resp: SemanticFocusResponse = serde_json::from_value(raw).unwrap();
379        assert_eq!(resp.count, 0);
380    }
381
382    #[test]
383    fn promote_readme_request_serializes() {
384        let req = PromoteReadmeRequest {
385            chunks: vec![json!({"collection": "docs", "score": 0.8, "content": "README text"})],
386        };
387        let v = serde_json::to_value(&req).unwrap();
388        assert!(v["chunks"].is_array());
389    }
390
391    #[test]
392    fn promote_readme_response_deserializes() {
393        let raw = json!({ "promoted_chunks": [], "count": 0 });
394        let resp: PromoteReadmeResponse = serde_json::from_value(raw).unwrap();
395        assert_eq!(resp.count, 0);
396    }
397
398    #[test]
399    fn compress_evidence_round_trip() {
400        let req = CompressEvidenceRequest {
401            chunks: vec![json!({"collection": "c", "score": 1.0, "content": "x"})],
402            max_bullets: Some(5),
403            max_per_doc: Some(2),
404        };
405        let v = serde_json::to_value(&req).unwrap();
406        assert_eq!(v["max_bullets"], 5);
407
408        let raw = json!({ "bullets": [{"text": "b", "source_id": "s", "category": "Feature", "score": 0.9}], "count": 1 });
409        let resp: CompressEvidenceResponse = serde_json::from_value(raw).unwrap();
410        assert_eq!(resp.count, 1);
411    }
412
413    #[test]
414    fn answer_plan_round_trip() {
415        let plan = AnswerPlan {
416            sections: vec![json!({"title": "Intro", "bullets_count": 1, "bullets": []})],
417            total_bullets: 1,
418            sources: vec!["docs".into()],
419        };
420        let serialized = serde_json::to_value(&plan).unwrap();
421        let parsed: AnswerPlan = serde_json::from_value(serialized).unwrap();
422        assert_eq!(parsed.total_bullets, 1);
423        assert_eq!(parsed.sources[0], "docs");
424    }
425
426    #[test]
427    fn llm_prompt_deserializes() {
428        let raw = json!({ "prompt": "Answer: ...", "length": 10, "estimated_tokens": 2 });
429        let lp: LlmPrompt = serde_json::from_value(raw).unwrap();
430        assert_eq!(lp.prompt, "Answer: ...");
431        assert_eq!(lp.estimated_tokens, 2);
432    }
433
434    #[test]
435    fn render_prompt_request_serializes() {
436        let req = RenderPromptRequest {
437            plan: AnswerPlan {
438                sections: vec![],
439                total_bullets: 0,
440                sources: vec![],
441            },
442        };
443        let v = serde_json::to_value(&req).unwrap();
444        assert!(v["plan"].is_object());
445    }
446}