1use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use utoipa::ToSchema;
13use uuid::Uuid;
14
15#[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#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
77#[serde(rename_all = "camelCase")]
78pub struct SearchPayloadDTO {
79 #[serde(default = "default_search_type", alias = "search_type")]
81 pub search_type: WireSearchType,
82
83 #[serde(default)]
85 pub datasets: Option<Vec<String>>,
86
87 #[serde(default, alias = "dataset_ids")]
89 pub dataset_ids: Option<Vec<Uuid>>,
90
91 #[serde(default = "default_query")]
93 pub query: String,
94
95 #[serde(default = "default_system_prompt", alias = "system_prompt")]
97 pub system_prompt: Option<String>,
98
99 #[serde(default, alias = "node_name")]
101 pub node_name: Option<Vec<String>>,
102
103 #[serde(default = "default_top_k", alias = "top_k")]
105 pub top_k: Option<i32>,
106
107 #[serde(default, alias = "only_context")]
109 pub only_context: bool,
110
111 #[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#[derive(Debug, Clone, Serialize, ToSchema)]
159#[serde(rename_all = "camelCase")]
160pub struct SearchHistoryItemDTO {
161 pub id: Uuid,
162 pub text: String,
163 pub user: String,
165 #[serde(with = "crate::dto::util::iso8601_offset")]
169 pub created_at: DateTime<Utc>,
170}
171
172impl SearchHistoryItemDTO {
173 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#[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#[derive(Debug, Clone, Serialize, ToSchema)]
213pub struct ErrorResponseDTO {
214 pub error: String,
215 pub detail: Option<String>,
216}
217
218pub 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#[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 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}