Skip to main content

sqlite_graphrag/output/
responses.rs

1//! Serializable response payloads for `remember` and `recall`.
2//!
3//! These are the JSON contract itself; the schemas under `docs/schemas/`
4//! describe them and `tests/doc_contract_integration.rs` keeps the two aligned.
5
6use serde::Serialize;
7
8/// JSON payload emitted by the `remember` subcommand.
9///
10/// All fields are required by the JSON contract (see `docs/schemas/remember.schema.json`).
11/// `operation` is an alias of `action` for compatibility with clients using the old field name.
12///
13/// # Examples
14///
15/// ```
16/// use sqlite_graphrag::output::RememberResponse;
17///
18/// let resp = RememberResponse {
19///     memory_id: 1,
20///     name: "nota-inicial".into(),
21///     namespace: "global".into(),
22///     action: "created".into(),
23///     operation: "created".into(),
24///     version: 1,
25///     entities_persisted: 0,
26///     relationships_persisted: 0,
27///     relationships_truncated: false,
28///     chunks_created: 1,
29///     chunks_persisted: 0,
30///     urls_persisted: 0,
31///     extraction_method: None,
32///     merged_into_memory_id: None,
33///     warnings: vec![],
34///     created_at: 1_700_000_000,
35///     created_at_iso: "2023-11-14T22:13:20Z".into(),
36///     elapsed_ms: 42,
37///     name_was_normalized: false,
38///     original_name: None,
39///     backend_invoked: None,
40///     entities_created: vec![],
41///     enrich_recommended: vec![],
42/// };
43///
44/// let json = serde_json::to_string(&resp).unwrap();
45/// assert!(json.contains("\"memory_id\":1"));
46/// assert!(json.contains("\"elapsed_ms\":42"));
47/// assert!(json.contains("\"merged_into_memory_id\":null"));
48/// assert!(json.contains("\"urls_persisted\":0"));
49/// assert!(json.contains("\"relationships_truncated\":false"));
50/// ```
51#[derive(Serialize)]
52pub struct RememberResponse {
53    /// Memory identifier.
54    pub memory_id: i64,
55    /// Name of this item.
56    pub name: String,
57    /// Namespace scope.
58    pub namespace: String,
59    /// Action.
60    pub action: String,
61    /// Semantic alias of `action` for compatibility with the contract documented in SKILL.md.
62    pub operation: String,
63    /// Version number.
64    pub version: i64,
65    /// Entities persisted.
66    pub entities_persisted: usize,
67    /// Relationships persisted.
68    pub relationships_persisted: usize,
69    /// True when the relationship builder hit the cap before covering all entity pairs.
70    /// Callers can use this to decide whether to increase GRAPHRAG_MAX_RELATIONSHIPS_PER_MEMORY.
71    pub relationships_truncated: bool,
72    /// Total number of chunks the body was split into BEFORE dedup.
73    ///
74    /// For single-chunk bodies this equals 1 even though no row is added to
75    /// the `memory_chunks` table — the memory row itself acts as the chunk.
76    /// Use `chunks_persisted` to know how many rows were actually written.
77    pub chunks_created: usize,
78    /// Number of chunks actually written to chunks/embeddings tables. Always <= chunks_created.
79    ///
80    /// Equal when no chunk had identical normalized text already in DB; less when dedup skipped
81    /// some. Equals zero for single-chunk bodies (the memory row is the chunk) and equals
82    /// `chunks_created` for multi-chunk bodies. Added in v1.0.23 to disambiguate from
83    /// `chunks_created` and reflect database state precisely.
84    pub chunks_persisted: usize,
85    /// Number of unique URLs inserted into `memory_urls` for this memory.
86    /// Added in v1.0.24 — split URLs out of the entity graph (P0-2 fix).
87    #[serde(default)]
88    pub urls_persisted: usize,
89    /// Extraction method used: "url-regex" when --enable-ner ran the URL-regex pass, or "none:extraction-failed" when extraction errored. None when NER is not enabled.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub extraction_method: Option<String>,
92    /// Merged into memory ID.
93    pub merged_into_memory_id: Option<i64>,
94    /// Warnings.
95    pub warnings: Vec<String>,
96    /// Timestamp Unix epoch seconds.
97    pub created_at: i64,
98    /// RFC 3339 UTC timestamp string parallel to `created_at` for ISO 8601 parsers.
99    pub created_at_iso: String,
100    /// Total execution time in milliseconds from handler start to serialisation.
101    pub elapsed_ms: u64,
102    /// True when the user-supplied `--name` differed from the persisted slug
103    /// (i.e. kebab-case normalization changed the value). Added in v1.0.32 so
104    /// callers can detect normalization without parsing stderr WARN logs.
105    #[serde(default)]
106    pub name_was_normalized: bool,
107    /// Original user-supplied `--name` value before normalization.
108    /// Present only when `name_was_normalized == true`; omitted otherwise to
109    /// keep the common (already-kebab) payload small.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub original_name: Option<String>,
112    /// v1.0.84 (ADR-0042): discriminator of the embedding backend that actually
113    /// ran the passage embedding. `"openrouter" | "none"`.
114    /// Absent on the wire when `None` (kept for happy-path envelope cleanliness).
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub backend_invoked: Option<&'static str>,
117    /// GAP-CLI-PRIO-01: entity names written/linked in this remember call
118    /// (hot set for priority entity-descriptions).
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub entities_created: Vec<String>,
121    /// GAP-CLI-PRIO-01 / G-T-ONESHOT-02: enrich operations the operator
122    /// should run next (e.g. `["entity-descriptions"]` after curated graph).
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub enrich_recommended: Vec<String>,
125}
126
127/// Individual item returned by the `recall` query.
128///
129/// The `memory_type` field is serialised as `"type"` in JSON to maintain
130/// compatibility with external clients — the Rust name uses `memory_type`
131/// to avoid conflict with the reserved keyword.
132///
133/// # Examples
134///
135/// ```
136/// use sqlite_graphrag::output::RecallItem;
137///
138/// let item = RecallItem {
139///     memory_id: 7,
140///     name: "nota-rust".into(),
141///     namespace: "global".into(),
142///     memory_type: "user".into(),
143///     description: "aprendizado de Rust".into(),
144///     snippet: "ownership e borrowing".into(),
145///     distance: 0.12,
146///     score: 0.88,
147///     source: "direct".into(),
148///     graph_depth: None,
149/// };
150///
151/// let json = serde_json::to_string(&item).unwrap();
152/// // Rust field `memory_type` appears as `"type"` in JSON.
153/// assert!(json.contains("\"type\":\"user\""));
154/// assert!(!json.contains("memory_type"));
155/// assert!(json.contains("\"distance\":0.12"));
156/// ```
157#[derive(Serialize, Clone)]
158pub struct RecallItem {
159    /// Memory identifier.
160    pub memory_id: i64,
161    /// Name of this item.
162    pub name: String,
163    /// Namespace scope.
164    pub namespace: String,
165    /// Memory type classification.
166    #[serde(rename = "type")]
167    pub memory_type: String,
168    /// Human-readable description.
169    pub description: String,
170    /// Snippet.
171    pub snippet: String,
172    /// Distance metric value.
173    pub distance: f32,
174    /// Cosine similarity in `[0.0, 1.0]` derived as `1.0 - distance` and clamped
175    /// to that interval. Always populated to satisfy the documented contract
176    /// (M-A5 in v1.0.40); higher means more similar. For graph hits the value
177    /// reflects the hop-derived distance proxy and should be interpreted
178    /// alongside `graph_depth` rather than as a true cosine score.
179    pub score: f32,
180    /// Source side of the relationship.
181    pub source: String,
182    /// Number of graph hops between this match and the seed memories.
183    ///
184    /// Set to `None` for direct vector matches (where `distance` is meaningful)
185    /// and to `Some(N)` for traversal results, with `N=0` when the depth could
186    /// not be tracked precisely. Added in v1.0.23 to disambiguate graph results
187    /// from the `distance: 0.0` placeholder previously used for graph entries.
188    /// Field is omitted from JSON output when `None`.
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub graph_depth: Option<u32>,
191}
192
193impl RecallItem {
194    /// Computes the similarity score from a vector distance, clamped to
195    /// `[0.0, 1.0]`. Cosine distance returned by sqlite-vec lives in `[0, 2]`
196    /// in theory but the embedder produces unit-norm vectors so the practical
197    /// range is `[0, 1]`. Centralized so every constructor keeps the contract.
198    #[inline]
199    pub fn score_from_distance(distance: f32) -> f32 {
200        let raw = 1.0 - distance;
201        if raw.is_nan() {
202            0.0
203        } else {
204            raw.clamp(0.0, 1.0)
205        }
206    }
207}
208
209/// Full response envelope returned by the `recall` subcommand.
210///
211/// Contains both direct vector matches and graph-traversal matches, plus the
212/// aggregated `results` list that merges both for callers that do not need
213/// to distinguish the source.
214#[derive(Serialize)]
215pub struct RecallResponse {
216    /// Search query text.
217    pub query: String,
218    /// Maximum number of results to return.
219    pub k: usize,
220    /// Direct matches.
221    pub direct_matches: Vec<RecallItem>,
222    /// Graph matches.
223    pub graph_matches: Vec<RecallItem>,
224    /// Aggregated alias of `direct_matches` + `graph_matches` for the contract documented in SKILL.md.
225    pub results: Vec<RecallItem>,
226    /// Total execution time in milliseconds from handler start to serialisation.
227    pub elapsed_ms: u64,
228    /// G58 (v1.0.80): `true` when the live query embedding failed and the
229    /// handler fell back to FTS5 BM25 + LIKE prefix. Symmetric to
230    /// `fts_degraded` in `hybrid-search`. Absent on the wire when false.
231    #[serde(skip_serializing_if = "std::ops::Not::not", default)]
232    pub vec_degraded: bool,
233    /// G58 (v1.0.80): human-readable description of the embedding failure
234    /// that triggered the fallback. Absent on the wire when `vec_degraded`
235    /// is false or the failure had no message.
236    #[serde(skip_serializing_if = "std::option::Option::is_none")]
237    pub vec_error: Option<String>,
238    /// G58 (v1.0.80): advisory warning echoed for callers that branch on
239    /// top-level status. Distinguishes a FTS5-only fallback from a clean
240    /// hybrid response so downstream pipelines can lower their confidence.
241    #[serde(skip_serializing_if = "std::option::Option::is_none")]
242    pub warning: Option<String>,
243    /// v1.0.84 (ADR-0042): discriminator of the embedding backend that actually
244    /// ran the live embedding. `"openrouter" | "none"`. Absent
245    /// on the wire when `None` (kept for happy-path envelope cleanliness).
246    #[serde(skip_serializing_if = "std::option::Option::is_none")]
247    pub backend_invoked: Option<&'static str>,
248    /// Operator-facing PROSE for the degradation, not a closed set.
249    ///
250    /// The name says `reason` and the published document said `enum` for four
251    /// releases, but what lands here is `FallbackReason`'s `Display` —
252    /// `"embedding failed: {msg}"`, carrying the provider's own message. Any
253    /// new provider error is a new string, so no enum could ever have held.
254    /// GAP-SG-290 measured this; the machine-readable half now travels beside
255    /// it in [`Self::vec_degraded_code`] rather than replacing this field,
256    /// because the envelope has always carried the prose and changing it would
257    /// break consumers reading it.
258    #[serde(skip_serializing_if = "std::option::Option::is_none")]
259    pub vec_degraded_reason: Option<String>,
260    /// v1.2.8 (GAP-SG-290): stable, machine-readable code for the degradation.
261    ///
262    /// This is `FallbackReason::reason_code()` — the eight-value set a consumer
263    /// can actually match on: the seven from that method plus
264    /// `FALLBACK_FTS_ONLY_CODE` for the degradation an operator ASKED for.
265    /// Absent on the wire when `vec_degraded` is false, so the happy-path
266    /// envelope is byte-identical and no existing consumer sees a new field.
267    #[serde(skip_serializing_if = "std::option::Option::is_none")]
268    pub vec_degraded_code: Option<&'static str>,
269}