Skip to main content

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}