gemini_memory_rs/retrieval/
extractor.rs1use async_trait::async_trait;
9use chrono::{DateTime, Utc};
10use std::sync::Arc;
11use std::time::Duration;
12
13use super::deterministic::DeterministicPlanner;
14use super::plan::RetrievalPlan;
15use crate::core::{MemoryError, TurnId};
16
17pub const RETRIEVAL_PLAN_INSTRUCTION: &str = "\
23You produce a retrieval plan for a personal memory system. Given the user's \
24most recent utterance and a little surrounding conversation, decide which of \
25the user's existing stored memories might be relevant to answering them.
26
27Return only the structured plan. Specifically:
28- Do NOT answer the user's question.
29- Do NOT propose anything to remember; that is a separate task.
30- Do NOT treat the assistant's own statements as facts about the user.
31- Do NOT infer sensitive attributes (health, religion, politics, sexuality) \
32 that the user did not state.
33- Set requires_memory to false for generic factual, visual or world-knowledge \
34 questions that do not depend on this user's history.
35- Prefer few, specific search terms over many broad ones.
36- When the user is not speaking English, put BOTH their words and the English \
37 equivalents in topics and lexical_queries. Stored facts are written in \
38 English and retrieval is literal word matching, so a question about \
39 'khaana' finds nothing unless the plan also carries 'food' and 'diet'. \
40 English on both sides is what makes the two meet; do not rely on the stored \
41 fact having guessed which Hindi or Tamil word would be used months later.";
42
43#[derive(Debug, Clone)]
45pub struct RetrievalExtractionContext {
46 pub transcript: String,
48 pub recent_user_turns: Vec<String>,
50 pub recent_assistant_turns: Vec<String>,
52 pub known_entities: Vec<String>,
54 pub deterministic: RetrievalPlan,
56 pub turn_id: TurnId,
58 pub generation: u64,
60 pub now: DateTime<Utc>,
62}
63
64impl RetrievalExtractionContext {
65 pub fn to_prompt(&self) -> String {
67 let mut out = String::new();
68 if !self.recent_user_turns.is_empty() {
69 out.push_str("Earlier user turns:\n");
70 for turn in &self.recent_user_turns {
71 out.push_str("- ");
72 out.push_str(turn);
73 out.push('\n');
74 }
75 }
76 if !self.recent_assistant_turns.is_empty() {
77 out.push_str("\nAssistant turns (for reference resolution only):\n");
78 for turn in &self.recent_assistant_turns {
79 out.push_str("- ");
80 out.push_str(turn);
81 out.push('\n');
82 }
83 }
84 if !self.known_entities.is_empty() {
85 out.push_str("\nKnown entities: ");
86 out.push_str(&self.known_entities.join(", "));
87 out.push('\n');
88 }
89 out.push_str("\nCurrent user utterance:\n");
90 out.push_str(&self.transcript);
91 out.push('\n');
92 out
93 }
94}
95
96#[async_trait]
98pub trait RetrievalPlanExtractor: Send + Sync {
99 async fn extract(
101 &self,
102 context: RetrievalExtractionContext,
103 ) -> Result<RetrievalPlan, MemoryError>;
104}
105
106pub struct DeterministicPlanExtractor {
111 planner: Arc<DeterministicPlanner>,
112}
113
114impl DeterministicPlanExtractor {
115 pub fn new(planner: Arc<DeterministicPlanner>) -> Self {
117 Self { planner }
118 }
119}
120
121#[async_trait]
122impl RetrievalPlanExtractor for DeterministicPlanExtractor {
123 async fn extract(
124 &self,
125 context: RetrievalExtractionContext,
126 ) -> Result<RetrievalPlan, MemoryError> {
127 Ok(self.planner.plan(
128 &context.transcript,
129 context.turn_id,
130 context.generation,
131 context.now,
132 ))
133 }
134}
135
136pub struct BoundedPlanExtractor {
141 inner: Arc<dyn RetrievalPlanExtractor>,
142 timeout: Duration,
143}
144
145impl BoundedPlanExtractor {
146 pub fn new(inner: Arc<dyn RetrievalPlanExtractor>, timeout: Duration) -> Self {
148 Self { inner, timeout }
149 }
150}
151
152#[async_trait]
153impl RetrievalPlanExtractor for BoundedPlanExtractor {
154 async fn extract(
155 &self,
156 context: RetrievalExtractionContext,
157 ) -> Result<RetrievalPlan, MemoryError> {
158 let fallback = context.deterministic.clone();
159 match tokio::time::timeout(self.timeout, self.inner.extract(context)).await {
160 Ok(Ok(plan)) => Ok(plan.normalized()),
161 Ok(Err(_)) | Err(_) => Ok(fallback),
162 }
163 }
164}
165
166pub fn retrieval_plan_schema() -> serde_json::Value {
168 let schema = schemars::schema_for!(RetrievalPlan);
169 serde_json::to_value(schema).unwrap_or(serde_json::Value::Null)
170}
171
172pub fn context_for(
174 planner: &DeterministicPlanner,
175 transcript: &str,
176 turn_id: TurnId,
177 generation: u64,
178 now: DateTime<Utc>,
179) -> RetrievalExtractionContext {
180 RetrievalExtractionContext {
181 transcript: transcript.to_string(),
182 recent_user_turns: Vec::new(),
183 recent_assistant_turns: Vec::new(),
184 known_entities: Vec::new(),
185 deterministic: planner.plan(transcript, turn_id, generation, now),
186 turn_id,
187 generation,
188 now,
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::retrieval::deterministic::KnownEntities;
196 use crate::retrieval::plan::RetrievalIntent;
197
198 fn planner() -> Arc<DeterministicPlanner> {
199 let mut known = KnownEntities::new();
200 known.insert("Rhea", "rhea");
201 Arc::new(DeterministicPlanner::with_entities(known))
202 }
203
204 fn context(text: &str) -> RetrievalExtractionContext {
205 context_for(&planner(), text, TurnId(2), 2, Utc::now())
206 }
207
208 struct AlwaysFails;
209
210 #[async_trait]
211 impl RetrievalPlanExtractor for AlwaysFails {
212 async fn extract(
213 &self,
214 _context: RetrievalExtractionContext,
215 ) -> Result<RetrievalPlan, MemoryError> {
216 Err(MemoryError::Extraction("model unavailable".into()))
217 }
218 }
219
220 struct Hangs;
221
222 #[async_trait]
223 impl RetrievalPlanExtractor for Hangs {
224 async fn extract(
225 &self,
226 _context: RetrievalExtractionContext,
227 ) -> Result<RetrievalPlan, MemoryError> {
228 tokio::time::sleep(Duration::from_secs(30)).await;
229 unreachable!("the bound should fire first")
230 }
231 }
232
233 #[tokio::test]
234 async fn the_deterministic_extractor_produces_a_usable_plan() {
235 let plan = DeterministicPlanExtractor::new(planner())
236 .extract(context("what does Rhea like to eat"))
237 .await
238 .unwrap();
239 assert!(plan.requires_memory);
240 assert!(!plan.lexical_queries.is_empty());
241 }
242
243 #[tokio::test]
244 async fn a_failing_model_extractor_degrades_to_the_rule_based_plan() {
245 let bounded = BoundedPlanExtractor::new(Arc::new(AlwaysFails), Duration::from_millis(50));
246 let plan = bounded
247 .extract(context("what does Rhea like to eat"))
248 .await
249 .unwrap();
250 assert!(plan.requires_memory);
251 assert_eq!(plan.intent, RetrievalIntent::RelationshipReference);
252 }
253
254 #[tokio::test]
255 async fn a_hanging_model_extractor_is_abandoned_at_the_deadline() {
256 let bounded = BoundedPlanExtractor::new(Arc::new(Hangs), Duration::from_millis(20));
257 let started = std::time::Instant::now();
258 let plan = bounded
259 .extract(context("what does Rhea like to eat"))
260 .await
261 .unwrap();
262 assert!(started.elapsed() < Duration::from_secs(1));
263 assert!(plan.requires_memory);
264 }
265
266 #[test]
267 fn the_prompt_separates_user_turns_from_assistant_turns() {
268 let mut ctx = context("what should we cook");
269 ctx.recent_user_turns = vec!["I am pescatarian".into()];
270 ctx.recent_assistant_turns = vec!["You mentioned salmon".into()];
271 let prompt = ctx.to_prompt();
272 assert!(prompt.contains("Earlier user turns:"));
273 assert!(prompt.contains("reference resolution only"));
274 assert!(prompt.ends_with("what should we cook\n"));
275 }
276
277 #[test]
278 fn the_instruction_forbids_answering_and_storing() {
279 assert!(RETRIEVAL_PLAN_INSTRUCTION.contains("Do NOT answer"));
280 assert!(RETRIEVAL_PLAN_INSTRUCTION.contains("separate task"));
281 }
282
283 #[test]
284 fn the_plan_schema_is_a_json_object_schema() {
285 let schema = retrieval_plan_schema();
286 assert_eq!(schema["type"], "object");
287 assert!(schema["properties"]["requires_memory"].is_object());
288 }
289}