Skip to main content

cognee_http_server/dto/
search.rs

1//! DTOs for `/api/v1/search` (and shared with `/api/v1/recall`).
2//!
3//! Wire-shape mirrors Python's `SearchPayloadDTO`, `SearchHistoryItem`,
4//! `SearchResult`, and `ErrorResponse` from
5//! [`cognee/api/v1/search/routers/get_search_router.py`](https://github.com/topoteretes/cognee/blob/main/cognee/api/v1/search/routers/get_search_router.py).
6//!
7//! See `docs/http-server/routers/search.md` §4 for the field-by-field reference.
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use utoipa::ToSchema;
13use uuid::Uuid;
14
15// ─── Wire-facing SearchType ───────────────────────────────────────────────────
16
17/// Wire-facing search-type enum mirroring Python's `SearchType` byte-for-byte.
18///
19/// Note: the Rust core enum (`cognee_search::types::SearchType`) carries an
20/// extra `Feedback` variant that has no Python counterpart. Per the audit
21/// outcome documented in `docs/http-server/routers/search.md` §6 Q1, the wire
22/// DTO drops `Feedback` entirely. Library callers can still reach the internal
23/// variant via `cognee_search::types::SearchType` directly; HTTP requests that
24/// supply `"FEEDBACK"` deserialize as a validation error instead.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, ToSchema)]
26#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
27pub enum WireSearchType {
28    Summaries,
29    Chunks,
30    RagCompletion,
31    TripletCompletion,
32    #[default]
33    GraphCompletion,
34    GraphSummaryCompletion,
35    Cypher,
36    NaturalLanguage,
37    GraphCompletionCot,
38    GraphCompletionContextExtension,
39    FeelingLucky,
40    Temporal,
41    CodingRules,
42    ChunksLexical,
43}
44
45impl From<WireSearchType> for cognee_search::types::SearchType {
46    fn from(value: WireSearchType) -> Self {
47        use cognee_search::types::SearchType as Core;
48        match value {
49            WireSearchType::Summaries => Core::Summaries,
50            WireSearchType::Chunks => Core::Chunks,
51            WireSearchType::RagCompletion => Core::RagCompletion,
52            WireSearchType::TripletCompletion => Core::TripletCompletion,
53            WireSearchType::GraphCompletion => Core::GraphCompletion,
54            WireSearchType::GraphSummaryCompletion => Core::GraphSummaryCompletion,
55            WireSearchType::Cypher => Core::Cypher,
56            WireSearchType::NaturalLanguage => Core::NaturalLanguage,
57            WireSearchType::GraphCompletionCot => Core::GraphCompletionCot,
58            WireSearchType::GraphCompletionContextExtension => {
59                Core::GraphCompletionContextExtension
60            }
61            WireSearchType::FeelingLucky => Core::FeelingLucky,
62            WireSearchType::Temporal => Core::Temporal,
63            WireSearchType::CodingRules => Core::CodingRules,
64            WireSearchType::ChunksLexical => Core::ChunksLexical,
65        }
66    }
67}
68
69// ─── SearchPayloadDTO ─────────────────────────────────────────────────────────
70
71/// Mirrors Python `SearchPayloadDTO` in
72/// [`get_search_router.py:25-36`](https://github.com/topoteretes/cognee/blob/main/cognee/api/v1/search/routers/get_search_router.py#L25-L36).
73///
74/// `SearchPayloadDTO` inherits `InDTO`, so the wire is camelCase per Decision
75/// 10 with snake_case accepted as an inbound alias.
76#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
77#[serde(rename_all = "camelCase")]
78pub struct SearchPayloadDTO {
79    /// Python: `search_type: SearchType = SearchType.GRAPH_COMPLETION`
80    #[serde(default = "default_search_type", alias = "search_type")]
81    pub search_type: WireSearchType,
82
83    /// Python: `datasets: Optional[list[str]] = None`
84    #[serde(default)]
85    pub datasets: Option<Vec<String>>,
86
87    /// Python: `dataset_ids: Optional[list[UUID]] = None`
88    #[serde(default, alias = "dataset_ids")]
89    pub dataset_ids: Option<Vec<Uuid>>,
90
91    /// Python: `query: str = "What is in the document?"`
92    #[serde(default = "default_query")]
93    pub query: String,
94
95    /// Python: `system_prompt: Optional[str] = "Answer the question..."`.
96    #[serde(default = "default_system_prompt", alias = "system_prompt")]
97    pub system_prompt: Option<String>,
98
99    /// Python: `node_name: Optional[list[str]] = None`
100    #[serde(default, alias = "node_name")]
101    pub node_name: Option<Vec<String>>,
102
103    /// Python: `top_k: Optional[int] = 10`
104    #[serde(default = "default_top_k", alias = "top_k")]
105    pub top_k: Option<i32>,
106
107    /// Python: `only_context: bool = False`
108    #[serde(default, alias = "only_context")]
109    pub only_context: bool,
110
111    /// Python: `verbose: bool = False`
112    #[serde(default)]
113    pub verbose: bool,
114}
115
116pub(crate) fn default_search_type() -> WireSearchType {
117    WireSearchType::GraphCompletion
118}
119
120pub(crate) fn default_query() -> String {
121    "What is in the document?".to_string()
122}
123
124pub(crate) fn default_system_prompt() -> Option<String> {
125    Some("Answer the question using the provided context. Be as brief as possible.".to_string())
126}
127
128pub(crate) fn default_top_k() -> Option<i32> {
129    Some(10)
130}
131
132impl Default for SearchPayloadDTO {
133    fn default() -> Self {
134        Self {
135            search_type: default_search_type(),
136            datasets: None,
137            dataset_ids: None,
138            query: default_query(),
139            system_prompt: default_system_prompt(),
140            node_name: None,
141            top_k: default_top_k(),
142            only_context: false,
143            verbose: false,
144        }
145    }
146}
147
148// ─── SearchHistoryItemDTO ─────────────────────────────────────────────────────
149
150/// Mirrors Python's inline `SearchHistoryItem` (`get_search_router.py:42-46`).
151///
152/// Carries only the four fields the frontend relies on; the underlying
153/// `SearchHistoryEntry` row has `query_id`/`entry_type`/`query_type` columns
154/// that are intentionally not exposed for Python parity.
155///
156/// `SearchHistoryItem` inherits `OutDTO` in Python, so the wire is camelCase
157/// (e.g. `createdAt`) per Decision 10.
158#[derive(Debug, Clone, Serialize, ToSchema)]
159#[serde(rename_all = "camelCase")]
160pub struct SearchHistoryItemDTO {
161    pub id: Uuid,
162    pub text: String,
163    /// `"user"` for query rows, `"system"` for result rows.
164    pub user: String,
165    /// Wire format: RFC 3339 with explicit `+00:00` offset and microsecond
166    /// precision (Python parity per Decision 6). See
167    /// [`crate::dto::util::iso8601_offset`].
168    #[serde(with = "crate::dto::util::iso8601_offset")]
169    pub created_at: DateTime<Utc>,
170}
171
172impl SearchHistoryItemDTO {
173    /// Project a `cognee_database::SearchHistoryEntry` onto the wire shape.
174    pub fn from_entry(entry: cognee_database::SearchHistoryEntry) -> Self {
175        let user = match entry.entry_type {
176            cognee_database::SearchHistoryEntryType::Query => "user",
177            cognee_database::SearchHistoryEntryType::Result => "system",
178        };
179        Self {
180            id: entry.entry_id,
181            text: entry.content,
182            user: user.to_string(),
183            created_at: entry.created_at,
184        }
185    }
186}
187
188// ─── SearchResultDTO ──────────────────────────────────────────────────────────
189
190/// Mirrors Python `SearchResult` (`cognee/modules/search/types/SearchResult.py`).
191///
192/// `search_result` is polymorphic — see `flatten_search_response` for the
193/// per-`SearchOutput` variant mapping.
194///
195/// `SearchResult` inherits `OutDTO` in Python, so the wire is camelCase
196/// (`searchResult`, `datasetId`, `datasetName`) per Decision 10.
197#[derive(Debug, Clone, Serialize, ToSchema)]
198#[serde(rename_all = "camelCase")]
199pub struct SearchResultDTO {
200    pub search_result: Value,
201    pub dataset_id: Option<Uuid>,
202    pub dataset_name: Option<String>,
203}
204
205// ─── ErrorResponseDTO ─────────────────────────────────────────────────────────
206
207/// Mirrors Python's `ErrorResponse {error, detail}` from
208/// [`cognee/api/DTO.py`](https://github.com/topoteretes/cognee/blob/main/cognee/api/DTO.py).
209///
210/// Used by `/api/v1/search`. The recall router uses a different envelope —
211/// see `crate::error::RecallErrorBody`.
212#[derive(Debug, Clone, Serialize, ToSchema)]
213pub struct ErrorResponseDTO {
214    pub error: String,
215    pub detail: Option<String>,
216}
217
218// ─── flatten_search_response ──────────────────────────────────────────────────
219
220/// Flatten a `SearchResponse` into the Python-shaped wire `Vec<SearchResultDTO>`.
221///
222/// The `SearchOutput` enum's variant determines the JSON shape of the
223/// `search_result` field:
224///
225/// - `Text(s)`              → `search_result: <string>`
226/// - `Items(items)`         → `search_result: <array of items>`
227/// - `Texts(strings)`       → `search_result: <array of strings>`
228/// - `GraphQueryRows(rows)` → `search_result: <array of arrays>`
229/// - `Rules(rules)`         → `search_result: <array of {node_set, text}>`
230/// - `Structured(value)`    → `search_result: <value>`
231/// - `Ack { message }`      → `search_result: {"message": "..."}`
232///
233/// See `docs/http-server/routers/search.md` §4 ("Wire shape of `search_result`").
234pub fn flatten_search_response(
235    response: cognee_search::types::SearchResponse,
236) -> Vec<SearchResultDTO> {
237    use cognee_search::types::SearchOutput;
238
239    let dataset_id = response.datasets.as_ref().and_then(|d| d.first().copied());
240
241    let search_result = match response.result {
242        SearchOutput::Text(s) => Value::String(s),
243        SearchOutput::Items(items) => {
244            serde_json::to_value(items).unwrap_or(Value::Array(Vec::new()))
245        }
246        SearchOutput::Texts(texts) => {
247            serde_json::to_value(texts).unwrap_or(Value::Array(Vec::new()))
248        }
249        SearchOutput::GraphQueryRows(rows) => {
250            serde_json::to_value(rows).unwrap_or(Value::Array(Vec::new()))
251        }
252        SearchOutput::Rules(rules) => {
253            serde_json::to_value(rules).unwrap_or(Value::Array(Vec::new()))
254        }
255        SearchOutput::Structured(v) => v,
256        SearchOutput::Ack { message } => serde_json::json!({"message": message}),
257    };
258
259    vec![SearchResultDTO {
260        search_result,
261        dataset_id,
262        dataset_name: None,
263    }]
264}
265
266// ─── Unit tests ──────────────────────────────────────────────────────────────
267
268#[cfg(test)]
269#[allow(
270    clippy::unwrap_used,
271    clippy::expect_used,
272    reason = "test code — panics are acceptable failures"
273)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_empty_post_body_round_trips_with_defaults() {
279        let payload: SearchPayloadDTO = serde_json::from_str("{}").expect("parse empty body");
280        assert_eq!(payload.search_type, WireSearchType::GraphCompletion);
281        assert_eq!(payload.query, "What is in the document?");
282        assert_eq!(
283            payload.system_prompt.as_deref(),
284            Some("Answer the question using the provided context. Be as brief as possible.")
285        );
286        assert_eq!(payload.top_k, Some(10));
287        assert!(!payload.only_context);
288        assert!(!payload.verbose);
289        assert!(payload.datasets.is_none());
290        assert!(payload.dataset_ids.is_none());
291        assert!(payload.node_name.is_none());
292    }
293
294    #[test]
295    fn test_every_wire_search_type_deserializes() {
296        let cases = [
297            ("SUMMARIES", WireSearchType::Summaries),
298            ("CHUNKS", WireSearchType::Chunks),
299            ("RAG_COMPLETION", WireSearchType::RagCompletion),
300            ("TRIPLET_COMPLETION", WireSearchType::TripletCompletion),
301            ("GRAPH_COMPLETION", WireSearchType::GraphCompletion),
302            (
303                "GRAPH_SUMMARY_COMPLETION",
304                WireSearchType::GraphSummaryCompletion,
305            ),
306            ("CYPHER", WireSearchType::Cypher),
307            ("NATURAL_LANGUAGE", WireSearchType::NaturalLanguage),
308            ("GRAPH_COMPLETION_COT", WireSearchType::GraphCompletionCot),
309            (
310                "GRAPH_COMPLETION_CONTEXT_EXTENSION",
311                WireSearchType::GraphCompletionContextExtension,
312            ),
313            ("FEELING_LUCKY", WireSearchType::FeelingLucky),
314            ("TEMPORAL", WireSearchType::Temporal),
315            ("CODING_RULES", WireSearchType::CodingRules),
316            ("CHUNKS_LEXICAL", WireSearchType::ChunksLexical),
317        ];
318
319        for (wire, expected) in cases {
320            let json = format!("{{\"search_type\": \"{wire}\"}}");
321            let payload: SearchPayloadDTO =
322                serde_json::from_str(&json).unwrap_or_else(|e| panic!("{wire}: {e}"));
323            assert_eq!(payload.search_type, expected, "wire {wire}");
324        }
325    }
326
327    #[test]
328    fn test_feedback_variant_is_dropped_from_wire() {
329        // Audit decision: `FEEDBACK` is not in the Python enum, so the wire
330        // refuses it. Library callers reach `SearchType::Feedback` via the
331        // core enum, never through this DTO.
332        let json = r#"{"search_type": "FEEDBACK"}"#;
333        let res: Result<SearchPayloadDTO, _> = serde_json::from_str(json);
334        assert!(
335            res.is_err(),
336            "FEEDBACK must NOT deserialize on the wire-facing DTO"
337        );
338    }
339
340    #[test]
341    fn test_flatten_text_output() {
342        use cognee_search::types::{SearchOutput, SearchResponse, SearchType};
343
344        let response = SearchResponse::from_output(
345            SearchType::GraphCompletion,
346            SearchOutput::Text("hello".to_string()),
347        );
348        let dto_list = flatten_search_response(response);
349        assert_eq!(dto_list.len(), 1);
350        assert_eq!(
351            dto_list[0].search_result,
352            Value::String("hello".to_string())
353        );
354    }
355
356    #[test]
357    fn test_flatten_items_output() {
358        use cognee_search::types::{SearchItem, SearchOutput, SearchResponse, SearchType};
359
360        let items = vec![SearchItem {
361            id: None,
362            score: Some(0.5),
363            payload: serde_json::json!({"text": "chunk"}),
364        }];
365        let response = SearchResponse::from_output(SearchType::Chunks, SearchOutput::Items(items));
366        let dto_list = flatten_search_response(response);
367        assert_eq!(dto_list.len(), 1);
368        assert!(dto_list[0].search_result.is_array());
369    }
370
371    #[test]
372    fn test_flatten_graph_query_rows() {
373        use cognee_search::types::{SearchOutput, SearchResponse, SearchType};
374
375        let rows = vec![vec![
376            Value::String("a".to_string()),
377            Value::Number(1.into()),
378        ]];
379        let response =
380            SearchResponse::from_output(SearchType::Cypher, SearchOutput::GraphQueryRows(rows));
381        let dto_list = flatten_search_response(response);
382        let arr = dto_list[0].search_result.as_array().expect("array");
383        assert_eq!(arr.len(), 1);
384    }
385
386    #[test]
387    fn test_flatten_rules_output() {
388        use cognee_search::types::{Rule, SearchOutput, SearchResponse, SearchType};
389
390        let rules = vec![Rule {
391            node_set: "ns".into(),
392            text: "always do X".into(),
393        }];
394        let response =
395            SearchResponse::from_output(SearchType::CodingRules, SearchOutput::Rules(rules));
396        let dto_list = flatten_search_response(response);
397        let arr = dto_list[0].search_result.as_array().expect("array");
398        assert_eq!(arr[0]["node_set"], "ns");
399        assert_eq!(arr[0]["text"], "always do X");
400    }
401
402    #[test]
403    fn test_flatten_structured_output() {
404        use cognee_search::types::{SearchOutput, SearchResponse, SearchType};
405
406        let value = serde_json::json!({"key": "val"});
407        let response = SearchResponse::from_output(
408            SearchType::GraphCompletion,
409            SearchOutput::Structured(value.clone()),
410        );
411        let dto_list = flatten_search_response(response);
412        assert_eq!(dto_list[0].search_result, value);
413    }
414
415    #[test]
416    fn test_flatten_ack_output() {
417        use cognee_search::types::{SearchOutput, SearchResponse, SearchType};
418
419        let response = SearchResponse::from_output(
420            SearchType::GraphCompletion,
421            SearchOutput::Ack {
422                message: "ok".into(),
423            },
424        );
425        let dto_list = flatten_search_response(response);
426        assert_eq!(dto_list[0].search_result["message"], "ok");
427    }
428
429    #[test]
430    fn search_dto_accepts_camelcase_input() {
431        let json = r#"{
432            "searchType": "GRAPH_COMPLETION",
433            "datasetIds": ["00000000-0000-0000-0000-000000000001"],
434            "systemPrompt": "sys",
435            "nodeName": ["n"],
436            "topK": 7,
437            "onlyContext": true
438        }"#;
439        let payload: SearchPayloadDTO = serde_json::from_str(json).expect("camelCase parse");
440        assert_eq!(payload.search_type, WireSearchType::GraphCompletion);
441        assert_eq!(payload.dataset_ids.as_ref().map(|v| v.len()), Some(1));
442        assert_eq!(payload.system_prompt.as_deref(), Some("sys"));
443        assert_eq!(payload.top_k, Some(7));
444        assert!(payload.only_context);
445    }
446
447    #[test]
448    fn search_dto_accepts_snake_case_input_via_alias() {
449        let json = r#"{
450            "search_type": "GRAPH_COMPLETION",
451            "dataset_ids": ["00000000-0000-0000-0000-000000000001"],
452            "system_prompt": "sys",
453            "node_name": ["n"],
454            "top_k": 7,
455            "only_context": true
456        }"#;
457        let payload: SearchPayloadDTO = serde_json::from_str(json).expect("snake_case parse");
458        assert_eq!(payload.search_type, WireSearchType::GraphCompletion);
459        assert_eq!(payload.dataset_ids.as_ref().map(|v| v.len()), Some(1));
460        assert_eq!(payload.system_prompt.as_deref(), Some("sys"));
461        assert_eq!(payload.top_k, Some(7));
462        assert!(payload.only_context);
463    }
464
465    #[test]
466    fn search_dto_serializes_camelcase_only() {
467        let dto = SearchPayloadDTO::default();
468        let s = serde_json::to_string(&dto).expect("serialize");
469        for k in [
470            "\"searchType\"",
471            "\"systemPrompt\"",
472            "\"topK\"",
473            "\"onlyContext\"",
474        ] {
475            assert!(s.contains(k), "missing {k} in {s}");
476        }
477        for forbidden in [
478            "\"search_type\"",
479            "\"dataset_ids\"",
480            "\"system_prompt\"",
481            "\"node_name\"",
482            "\"top_k\"",
483            "\"only_context\"",
484        ] {
485            assert!(
486                !s.contains(forbidden),
487                "snake_case key {forbidden} leaked: {s}"
488            );
489        }
490    }
491
492    #[test]
493    fn search_history_item_dto_serializes_camelcase_only() {
494        let dto = SearchHistoryItemDTO {
495            id: Uuid::nil(),
496            text: "hi".into(),
497            user: "user".into(),
498            created_at: chrono::Utc::now(),
499        };
500        let s = serde_json::to_string(&dto).expect("serialize");
501        assert!(s.contains("\"createdAt\""), "missing createdAt: {s}");
502        assert!(
503            !s.contains("\"created_at\""),
504            "snake_case created_at leaked: {s}"
505        );
506    }
507
508    #[test]
509    fn search_result_dto_serializes_camelcase_only() {
510        let dto = SearchResultDTO {
511            search_result: Value::String("x".into()),
512            dataset_id: Some(Uuid::nil()),
513            dataset_name: Some("ds".into()),
514        };
515        let s = serde_json::to_string(&dto).expect("serialize");
516        for k in ["\"searchResult\"", "\"datasetId\"", "\"datasetName\""] {
517            assert!(s.contains(k), "missing {k} in {s}");
518        }
519        for forbidden in ["\"search_result\"", "\"dataset_id\"", "\"dataset_name\""] {
520            assert!(
521                !s.contains(forbidden),
522                "snake_case key {forbidden} leaked: {s}"
523            );
524        }
525    }
526
527    #[test]
528    fn test_history_item_user_field() {
529        use chrono::Utc;
530        use cognee_database::{SearchHistoryEntry, SearchHistoryEntryType};
531
532        let q = SearchHistoryEntry {
533            entry_id: Uuid::nil(),
534            query_id: Uuid::nil(),
535            entry_type: SearchHistoryEntryType::Query,
536            content: "hi".into(),
537            query_type: Some("GRAPH_COMPLETION".into()),
538            user_id: None,
539            created_at: Utc::now(),
540        };
541        assert_eq!(SearchHistoryItemDTO::from_entry(q).user, "user");
542
543        let r = SearchHistoryEntry {
544            entry_id: Uuid::nil(),
545            query_id: Uuid::nil(),
546            entry_type: SearchHistoryEntryType::Result,
547            content: "hi".into(),
548            query_type: None,
549            user_id: None,
550            created_at: Utc::now(),
551        };
552        assert_eq!(SearchHistoryItemDTO::from_entry(r).user, "system");
553    }
554}