Skip to main content

sqlite_graphrag/
output.rs

1use crate::errors::AppError;
2use serde::Serialize;
3
4#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
5pub enum OutputFormat {
6    #[default]
7    Json,
8    Text,
9    Markdown,
10}
11
12#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
13pub enum JsonOutputFormat {
14    #[default]
15    Json,
16}
17
18pub fn emit_json<T: Serialize>(value: &T) -> Result<(), AppError> {
19    let json = serde_json::to_string_pretty(value)?;
20    println!("{json}");
21    Ok(())
22}
23
24pub fn emit_json_compact<T: Serialize>(value: &T) -> Result<(), AppError> {
25    let json = serde_json::to_string(value)?;
26    println!("{json}");
27    Ok(())
28}
29
30pub fn emit_text(msg: &str) {
31    println!("{msg}");
32}
33
34pub fn emit_progress(msg: &str) {
35    tracing::info!(message = msg);
36}
37
38/// Emite mensagem de progresso bilíngue respeitando `--lang` ou `SQLITE_GRAPHRAG_LANG`.
39/// Uso: `output::emit_progress_i18n("Computing embedding...", "Calculando embedding...")`.
40pub fn emit_progress_i18n(en: &str, pt: &str) {
41    use crate::i18n::{current, Language};
42    match current() {
43        Language::English => tracing::info!(message = en),
44        Language::Portugues => tracing::info!(message = pt),
45    }
46}
47
48/// Payload JSON emitido pelo subcomando `remember`.
49///
50/// Todos os campos são obrigatórios no contrato JSON (ver `docs/schemas/remember.schema.json`).
51/// `operation` é alias de `action` para compatibilidade com clientes que usam o campo antigo.
52///
53/// # Examples
54///
55/// ```
56/// use sqlite_graphrag::output::RememberResponse;
57///
58/// let resp = RememberResponse {
59///     memory_id: 1,
60///     name: "nota-inicial".into(),
61///     namespace: "global".into(),
62///     action: "created".into(),
63///     operation: "created".into(),
64///     version: 1,
65///     entities_persisted: 0,
66///     relationships_persisted: 0,
67///     relationships_truncated: false,
68///     chunks_created: 1,
69///     chunks_persisted: 0,
70///     urls_persisted: 0,
71///     extraction_method: None,
72///     merged_into_memory_id: None,
73///     warnings: vec![],
74///     created_at: 1_700_000_000,
75///     created_at_iso: "2023-11-14T22:13:20Z".into(),
76///     elapsed_ms: 42,
77/// };
78///
79/// let json = serde_json::to_string(&resp).unwrap();
80/// assert!(json.contains("\"memory_id\":1"));
81/// assert!(json.contains("\"elapsed_ms\":42"));
82/// assert!(json.contains("\"merged_into_memory_id\":null"));
83/// assert!(json.contains("\"urls_persisted\":0"));
84/// assert!(json.contains("\"relationships_truncated\":false"));
85/// ```
86#[derive(Serialize)]
87pub struct RememberResponse {
88    pub memory_id: i64,
89    pub name: String,
90    pub namespace: String,
91    pub action: String,
92    /// Alias semântico de `action` para compatibilidade com contrato documentado em SKILL.md e AGENT_PROTOCOL.md.
93    pub operation: String,
94    pub version: i64,
95    pub entities_persisted: usize,
96    pub relationships_persisted: usize,
97    /// True when the relationship builder hit the cap before covering all entity pairs.
98    /// Callers can use this to decide whether to increase GRAPHRAG_MAX_RELATIONSHIPS_PER_MEMORY.
99    pub relationships_truncated: bool,
100    /// Total chunks produced by the hierarchical splitter for this body.
101    ///
102    /// For single-chunk bodies this equals 1 even though no row is added to
103    /// the `memory_chunks` table — the memory row itself acts as the chunk.
104    /// Use `chunks_persisted` to know how many rows were actually written.
105    pub chunks_created: usize,
106    /// Number of rows actually inserted into the `memory_chunks` table.
107    ///
108    /// Equals zero for single-chunk bodies (the memory row is the chunk) and
109    /// equals `chunks_created` for multi-chunk bodies. Added in v1.0.23 to
110    /// disambiguate from `chunks_created` and reflect database state precisely.
111    pub chunks_persisted: usize,
112    /// Number of unique URLs inserted into `memory_urls` for this memory.
113    /// Added in v1.0.24 — split URLs out of the entity graph (P0-2 fix).
114    #[serde(default)]
115    pub urls_persisted: usize,
116    /// Método de extração usado: "bert+regex" ou "regex-only". None se skip-extraction.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub extraction_method: Option<String>,
119    pub merged_into_memory_id: Option<i64>,
120    pub warnings: Vec<String>,
121    /// Timestamp Unix epoch seconds.
122    pub created_at: i64,
123    /// Timestamp RFC 3339 UTC string paralelo a `created_at` para parsers ISO 8601.
124    pub created_at_iso: String,
125    /// Tempo total de execução em milissegundos desde início do handler até serialização.
126    pub elapsed_ms: u64,
127}
128
129/// Item individual retornado pela consulta `recall`.
130///
131/// O campo `memory_type` é serializado como `"type"` no JSON para manter
132/// compatibilidade com clientes externos — o nome Rust usa `memory_type`
133/// para evitar conflito com a palavra reservada.
134///
135/// # Examples
136///
137/// ```
138/// use sqlite_graphrag::output::RecallItem;
139///
140/// let item = RecallItem {
141///     memory_id: 7,
142///     name: "nota-rust".into(),
143///     namespace: "global".into(),
144///     memory_type: "user".into(),
145///     description: "aprendizado de Rust".into(),
146///     snippet: "ownership e borrowing".into(),
147///     distance: 0.12,
148///     source: "direct".into(),
149///     graph_depth: None,
150/// };
151///
152/// let json = serde_json::to_string(&item).unwrap();
153/// // Campo Rust `memory_type` aparece como `"type"` no JSON.
154/// assert!(json.contains("\"type\":\"user\""));
155/// assert!(!json.contains("memory_type"));
156/// assert!(json.contains("\"distance\":0.12"));
157/// ```
158#[derive(Serialize, Clone)]
159pub struct RecallItem {
160    pub memory_id: i64,
161    pub name: String,
162    pub namespace: String,
163    #[serde(rename = "type")]
164    pub memory_type: String,
165    pub description: String,
166    pub snippet: String,
167    pub distance: f32,
168    pub source: String,
169    /// Number of graph hops between this match and the seed memories.
170    ///
171    /// Set to `None` for direct vector matches (where `distance` is meaningful)
172    /// and to `Some(N)` for traversal results, with `N=0` when the depth could
173    /// not be tracked precisely. Added in v1.0.23 to disambiguate graph results
174    /// from the `distance: 0.0` placeholder previously used for graph entries.
175    /// Field is omitted from JSON output when `None`.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub graph_depth: Option<u32>,
178}
179
180#[derive(Serialize)]
181pub struct RecallResponse {
182    pub query: String,
183    pub k: usize,
184    pub direct_matches: Vec<RecallItem>,
185    pub graph_matches: Vec<RecallItem>,
186    /// Alias agregado de `direct_matches` + `graph_matches` para contrato documentado em SKILL.md.
187    pub results: Vec<RecallItem>,
188    /// Tempo total de execução em milissegundos desde início do handler até serialização.
189    pub elapsed_ms: u64,
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use serde::Serialize;
196
197    #[derive(Serialize)]
198    struct Dummy {
199        val: u32,
200    }
201
202    // Tipo não-serializável para forçar erro de serialização JSON
203    struct NotSerializable;
204    impl Serialize for NotSerializable {
205        fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
206            Err(serde::ser::Error::custom(
207                "falha intencional de serialização",
208            ))
209        }
210    }
211
212    #[test]
213    fn emit_json_retorna_ok_para_valor_valido() {
214        let v = Dummy { val: 42 };
215        assert!(emit_json(&v).is_ok());
216    }
217
218    #[test]
219    fn emit_json_retorna_erro_para_valor_nao_serializavel() {
220        let v = NotSerializable;
221        assert!(emit_json(&v).is_err());
222    }
223
224    #[test]
225    fn emit_json_compact_retorna_ok_para_valor_valido() {
226        let v = Dummy { val: 7 };
227        assert!(emit_json_compact(&v).is_ok());
228    }
229
230    #[test]
231    fn emit_json_compact_retorna_erro_para_valor_nao_serializavel() {
232        let v = NotSerializable;
233        assert!(emit_json_compact(&v).is_err());
234    }
235
236    #[test]
237    fn emit_text_nao_entra_em_panico() {
238        emit_text("mensagem de teste");
239    }
240
241    #[test]
242    fn emit_progress_nao_entra_em_panico() {
243        emit_progress("progresso de teste");
244    }
245
246    #[test]
247    fn remember_response_serializa_corretamente() {
248        let r = RememberResponse {
249            memory_id: 1,
250            name: "teste".to_string(),
251            namespace: "ns".to_string(),
252            action: "created".to_string(),
253            operation: "created".to_string(),
254            version: 1,
255            entities_persisted: 2,
256            relationships_persisted: 3,
257            relationships_truncated: false,
258            chunks_created: 4,
259            chunks_persisted: 4,
260            urls_persisted: 2,
261            extraction_method: None,
262            merged_into_memory_id: None,
263            warnings: vec!["aviso".to_string()],
264            created_at: 1776569715,
265            created_at_iso: "2026-04-19T03:34:15Z".to_string(),
266            elapsed_ms: 123,
267        };
268        let json = serde_json::to_string(&r).unwrap();
269        assert!(json.contains("memory_id"));
270        assert!(json.contains("aviso"));
271        assert!(json.contains("\"namespace\""));
272        assert!(json.contains("\"merged_into_memory_id\""));
273        assert!(json.contains("\"operation\""));
274        assert!(json.contains("\"created_at\""));
275        assert!(json.contains("\"created_at_iso\""));
276        assert!(json.contains("\"elapsed_ms\""));
277        assert!(json.contains("\"urls_persisted\""));
278        assert!(json.contains("\"relationships_truncated\":false"));
279    }
280
281    #[test]
282    fn recall_item_serializa_campo_type_renomeado() {
283        let item = RecallItem {
284            memory_id: 10,
285            name: "entidade".to_string(),
286            namespace: "ns".to_string(),
287            memory_type: "entity".to_string(),
288            description: "desc".to_string(),
289            snippet: "trecho".to_string(),
290            distance: 0.5,
291            source: "db".to_string(),
292            graph_depth: None,
293        };
294        let json = serde_json::to_string(&item).unwrap();
295        assert!(json.contains("\"type\""));
296        assert!(!json.contains("memory_type"));
297        // Field is omitted from JSON when None.
298        assert!(!json.contains("graph_depth"));
299    }
300
301    #[test]
302    fn recall_response_serializa_com_listas() {
303        let resp = RecallResponse {
304            query: "busca".to_string(),
305            k: 10,
306            direct_matches: vec![],
307            graph_matches: vec![],
308            results: vec![],
309            elapsed_ms: 42,
310        };
311        let json = serde_json::to_string(&resp).unwrap();
312        assert!(json.contains("direct_matches"));
313        assert!(json.contains("graph_matches"));
314        assert!(json.contains("\"k\":"));
315        assert!(json.contains("\"results\""));
316        assert!(json.contains("\"elapsed_ms\""));
317    }
318
319    #[test]
320    fn output_format_default_eh_json() {
321        let fmt = OutputFormat::default();
322        assert!(matches!(fmt, OutputFormat::Json));
323    }
324
325    #[test]
326    fn output_format_variantes_existem() {
327        let _text = OutputFormat::Text;
328        let _md = OutputFormat::Markdown;
329        let _json = OutputFormat::Json;
330    }
331
332    #[test]
333    fn recall_item_clone_produz_valor_igual() {
334        let item = RecallItem {
335            memory_id: 99,
336            name: "clone".to_string(),
337            namespace: "ns".to_string(),
338            memory_type: "relation".to_string(),
339            description: "d".to_string(),
340            snippet: "s".to_string(),
341            distance: 0.1,
342            source: "src".to_string(),
343            graph_depth: Some(2),
344        };
345        let cloned = item.clone();
346        assert_eq!(cloned.memory_id, item.memory_id);
347        assert_eq!(cloned.name, item.name);
348        assert_eq!(cloned.graph_depth, Some(2));
349    }
350}