cognee_http_server/dto/remember.rs
1//! DTOs for `POST /api/v1/remember`.
2
3use serde::{Deserialize, Serialize};
4use utoipa::ToSchema;
5
6// Re-export shared DTO.
7pub use super::pipeline_run::PipelineRunInfoDTO;
8
9// ─── Form fields ─────────────────────────────────────────────────────────────
10
11/// Parsed multipart form for `POST /api/v1/remember`.
12///
13/// Populated by the handler iterating over multipart parts; not derived via
14/// serde (multipart extraction is manual).
15#[derive(Debug, Default)]
16pub struct RememberFormDTO {
17 /// camelCase wire name: `datasetName`.
18 pub dataset_name: Option<String>,
19 /// camelCase wire name: `datasetId`. Empty string → `None`.
20 pub dataset_id: super::util::DatasetIdRef,
21 /// Repeated form field. `[""]` is translated to `None` after extraction.
22 pub node_set: Option<Vec<String>>,
23 /// `"true"` / `"1"` → `true`.
24 pub run_in_background: Option<bool>,
25 pub custom_prompt: Option<String>,
26 pub chunks_per_batch: Option<u32>,
27 /// Optional session id forwarded to `cognee.remember(session_id=...)` per
28 /// Python (`get_remember_router.py:34` / `:84`). Empty string is treated
29 /// as `None` (Python's `examples=[""]` is illustrative — empty is the
30 /// "absent" sentinel).
31 pub session_id: Option<String>,
32}
33
34// ─── Uploaded file part ───────────────────────────────────────────────────────
35
36/// One spooled file part from the multipart body.
37pub struct UploadedFilePart {
38 pub file_name: Option<String>,
39 pub content_type: Option<String>,
40 pub temp_path: std::path::PathBuf,
41 pub byte_count: u64,
42}
43
44// ─── Wire status enum ─────────────────────────────────────────────────────────
45
46/// Wire-format status for the `/remember` and `/remember/entry` HTTP responses.
47///
48/// Python's `RememberResult.to_dict()` emits these exact lowercase strings —
49/// see `cognee/api/v1/remember/remember.py:323-324, 480, 521, 720, 751`.
50///
51/// **Decision 15** (two-layer status convention): the library
52/// `cognee_lib::api::remember::RememberStatus` enum (LIB-06, commit b39cd05)
53/// emits CamelCase for internal Rust consistency with
54/// `cognee_core::PipelineRunStatus`. The HTTP layer translates back to
55/// Python's lowercase here for strict wire parity. **No wire divergence.**
56///
57/// The cross-crate `From<cognee_lib::api::remember::RememberStatus>`
58/// translation is **deferred to the P5 wiring task** because
59/// `cognee-http-server` cannot depend on `cognee-lib` (cycle constraint —
60/// `cognee-lib`'s `server` feature pulls in `cognee-http-server`).
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
62pub enum WireRememberStatus {
63 #[serde(rename = "running")]
64 Running,
65 #[serde(rename = "completed")]
66 Completed,
67 #[serde(rename = "errored")]
68 Errored,
69 #[serde(rename = "session_stored")]
70 SessionStored,
71}
72
73// ─── Per-item DTO ────────────────────────────────────────────────────────────
74
75/// Per-item result info attached to `RememberResultDTO.items`.
76///
77/// Mirrors the fields of `cognee_lib::api::remember::RememberItemInfo`
78/// (`crates/lib/src/api/remember.rs:72-82`) but is defined locally because
79/// `cognee-http-server` cannot depend on `cognee-lib` (cycle constraint).
80#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
81#[serde(rename_all = "snake_case")]
82pub struct RememberItemDTO {
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub name: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub content_hash: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub token_count: Option<i64>,
89}
90
91// ─── Response ─────────────────────────────────────────────────────────────────
92
93/// Response body for `POST /api/v1/remember`.
94///
95/// Wire shape mirrors Python's `RememberResult.to_dict()`
96/// (`cognee/api/v1/remember/remember.py:415-437`).
97///
98/// **CLEAN-01 carve-out**: `#[serde(rename_all = "snake_case")]` is preserved
99/// because Python's `RememberResult` is a plain class (not pydantic
100/// `BaseModel`), so its `to_dict()` produces snake_case keys directly and
101/// `jsonable_encoder()` does not apply alias conversion. See
102/// `docs/http-api-v2/tasks/clean-01-v1-dto-camelcase.md` §3.1 row for
103/// `dto/remember.rs`.
104#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
105#[serde(rename_all = "snake_case")]
106pub struct RememberResultDTO {
107 pub status: WireRememberStatus,
108 /// Python emits the key always (may be `null` on the session-stored path);
109 /// no `skip_serializing_if`.
110 pub pipeline_run_id: Option<uuid::Uuid>,
111 /// Python emits the key always (may be `null`); no `skip_serializing_if`.
112 pub dataset_id: Option<uuid::Uuid>,
113 pub dataset_name: String,
114 /// Always emitted (default 0). Mirrors Python's
115 /// `RememberResult.items_processed` (`remember.py:418`).
116 pub items_processed: u32,
117 /// Always emitted (`null` when absent). Mirrors Python's
118 /// `RememberResult.elapsed_seconds` (`remember.py:422`).
119 pub elapsed_seconds: Option<f64>,
120 /// Conditional — only emitted when set
121 /// (Python `if self.session_ids:` `remember.py:425-426`).
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub session_ids: Option<Vec<String>>,
124 /// Conditional (Python `if self.content_hash:` `remember.py:427-428`).
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub content_hash: Option<String>,
127 /// Conditional (Python `if self.items:` `remember.py:429-430`).
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub items: Option<Vec<RememberItemDTO>>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub error: Option<String>,
132 /// Discriminator string for the typed-entry path
133 /// (`"qa"` / `"trace"` / `"feedback"`).
134 ///
135 /// Reserved for `POST /api/v1/remember/entry` (E-02, Decision 5). Skipped
136 /// when `None` so the existing file-payload responses (E-01) stay
137 /// byte-identical (Python omits both keys on the file path).
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub entry_type: Option<String>,
140 /// Cache-returned entry id (`qa_id` / `trace_id`). For feedback entries
141 /// this is the input `qa_id` even when the QA was not found in the
142 /// session (Python parity at `remember.py:307`).
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub entry_id: Option<String>,
145}
146
147// ─── Tests ────────────────────────────────────────────────────────────────────
148
149#[cfg(test)]
150#[allow(
151 clippy::unwrap_used,
152 clippy::expect_used,
153 reason = "test code — panics are acceptable failures"
154)]
155mod tests {
156 use super::*;
157
158 /// Each `WireRememberStatus` variant must serialize to Python's exact
159 /// lowercase wire string (Decision 15).
160 #[test]
161 fn wire_remember_status_serde_roundtrip() {
162 let cases = [
163 (WireRememberStatus::Running, "\"running\""),
164 (WireRememberStatus::Completed, "\"completed\""),
165 (WireRememberStatus::Errored, "\"errored\""),
166 (WireRememberStatus::SessionStored, "\"session_stored\""),
167 ];
168 for (variant, expected) in cases {
169 let json = serde_json::to_string(&variant).expect("serialize");
170 assert_eq!(json, expected, "variant {variant:?} → {expected}");
171 let parsed: WireRememberStatus = serde_json::from_str(expected).expect("deserialize");
172 assert_eq!(parsed, variant, "round-trip {expected}");
173 }
174 }
175
176 /// `RememberResultDTO` must match Python's `RememberResult.to_dict()` wire
177 /// shape — required keys always present, conditional keys absent when
178 /// `None`. `dataset_id` / `pipeline_run_id` / `elapsed_seconds` are
179 /// always-emit (may be `null`); `session_ids` / `content_hash` / `items`
180 /// / `error` are skip-on-`None`.
181 #[test]
182 fn remember_result_dto_minimal_wire_shape() {
183 let dto = RememberResultDTO {
184 status: WireRememberStatus::Completed,
185 pipeline_run_id: None,
186 dataset_id: None,
187 dataset_name: "ds".into(),
188 items_processed: 0,
189 elapsed_seconds: None,
190 session_ids: None,
191 content_hash: None,
192 items: None,
193 error: None,
194 entry_type: None,
195 entry_id: None,
196 };
197 let v = serde_json::to_value(&dto).expect("to_value");
198 let obj = v.as_object().expect("object");
199
200 // Always-emitted keys.
201 assert_eq!(obj["status"], "completed");
202 assert!(obj.contains_key("pipeline_run_id"));
203 assert!(obj["pipeline_run_id"].is_null());
204 assert!(obj.contains_key("dataset_id"));
205 assert!(obj["dataset_id"].is_null());
206 assert_eq!(obj["dataset_name"], "ds");
207 assert_eq!(obj["items_processed"], 0);
208 assert!(obj.contains_key("elapsed_seconds"));
209 assert!(obj["elapsed_seconds"].is_null());
210
211 // Conditional keys must be absent when `None`.
212 assert!(!obj.contains_key("session_ids"));
213 assert!(!obj.contains_key("content_hash"));
214 assert!(!obj.contains_key("items"));
215 assert!(!obj.contains_key("error"));
216
217 // E-02 reserved keys must NOT appear here (Decision 5).
218 assert!(!obj.contains_key("entry_type"));
219 assert!(!obj.contains_key("entry_id"));
220 }
221
222 #[test]
223 fn remember_result_dto_populated_wire_shape() {
224 let dto = RememberResultDTO {
225 status: WireRememberStatus::SessionStored,
226 pipeline_run_id: None,
227 dataset_id: None,
228 dataset_name: "ds".into(),
229 items_processed: 3,
230 elapsed_seconds: Some(1.25),
231 session_ids: Some(vec!["sess-1".into()]),
232 content_hash: Some("abc123".into()),
233 items: Some(vec![RememberItemDTO {
234 name: Some("doc.txt".into()),
235 content_hash: Some("hash".into()),
236 token_count: Some(42),
237 }]),
238 error: None,
239 entry_type: None,
240 entry_id: None,
241 };
242 let v = serde_json::to_value(&dto).expect("to_value");
243 let obj = v.as_object().expect("object");
244
245 assert_eq!(obj["status"], "session_stored");
246 assert_eq!(obj["items_processed"], 3);
247 assert_eq!(obj["elapsed_seconds"], 1.25);
248 assert_eq!(obj["session_ids"][0], "sess-1");
249 assert_eq!(obj["content_hash"], "abc123");
250 let items = obj["items"].as_array().expect("items array");
251 assert_eq!(items[0]["name"], "doc.txt");
252 assert_eq!(items[0]["content_hash"], "hash");
253 assert_eq!(items[0]["token_count"], 42);
254
255 // Without `entry_type` / `entry_id` set, both keys must be absent
256 // — the file/text path of `RememberResultDTO` does not carry them
257 // (Python parity, Decision 5).
258 assert!(!obj.contains_key("entry_type"));
259 assert!(!obj.contains_key("entry_id"));
260 }
261
262 /// E-02, Decision 5: when the typed-entry handler populates the new
263 /// `entry_type` / `entry_id` fields, they must serialize to the wire
264 /// under their snake_case names alongside the rest of the DTO.
265 #[test]
266 fn remember_result_dto_serializes_entry_fields_when_set() {
267 let dto = RememberResultDTO {
268 status: WireRememberStatus::SessionStored,
269 pipeline_run_id: None,
270 dataset_id: None,
271 dataset_name: "main_dataset".into(),
272 items_processed: 0,
273 elapsed_seconds: Some(0.01),
274 session_ids: Some(vec!["sess-1".into()]),
275 content_hash: None,
276 items: None,
277 error: None,
278 entry_type: Some("qa".into()),
279 entry_id: Some("qa-abc-123".into()),
280 };
281 let v = serde_json::to_value(&dto).expect("to_value");
282 let obj = v.as_object().expect("object");
283
284 assert_eq!(obj["status"], "session_stored");
285 assert_eq!(obj["entry_type"], "qa");
286 assert_eq!(obj["entry_id"], "qa-abc-123");
287 }
288}