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}