1use async_trait::async_trait;
12use chrono::{DateTime, Utc};
13use std::sync::Arc;
14use std::time::Duration;
15
16use crate::core::{
17 CanonicalPredicate, EntityRef, Explicitness, MemoryError, MemoryKind, MemoryObservation,
18 MemoryValue, MutationIntent, ObservationId, ProposedPersistence, SensitivityClass, SessionId,
19 SpeakerAttribution, TemporalScope, TranscriptEvidence, TurnId, normalize_token,
20};
21
22pub const OBSERVATION_EXTRACTION_INSTRUCTION: &str = "\
24You extract candidate memories from a single finalized user utterance for a \
25personal memory system.
26
27Rules:
28- Only extract things the USER said about themselves or their life. Never \
29 extract from the assistant's turns, and never from speech attributed to \
30 anyone else.
31- Distinguish durable facts (preferences, relationships, identity, routines) \
32 from time-bounded events (plans, moods, one-off situations). Mark the latter \
33 episodic with an expected expiry.
34- Mark a transient feeling or a passing remark as session-only or discard it.
35- Set explicitness honestly. If the user did not say it, it is an inference, \
36 however obvious it seems.
37- Never infer sensitive attributes (health, religion, politics, sexuality) the \
38 user did not state outright.
39- Recognise explicit memory commands ('remember that…', 'forget…', 'actually, \
40 I…') and set mutation_intent accordingly.
41- Reuse a predicate from the 'Predicates already in use' list whenever the new \
42 fact is about the same thing, even when it CONTRADICTS the stored one. A \
43 correction must land on the same predicate as the fact it corrects, or it \
44 becomes a second record instead of replacing the first. Invent a new \
45 predicate only when nothing in the list covers the fact.
46- Write the fact itself in English, always, whatever language the user spoke. \
47 statement, predicate, value, subject and qualifier are the canonical record: \
48 one user saying 'main vegetarian hoon', 'naan vegetarian', and 'I am \
49 vegetarian' must produce the SAME predicate and the SAME value, so that the \
50 three reinforce one memory instead of creating three.
51- search_terms are the exception and must NOT be normalized to English. They \
52 are not a transcription of this sentence — they are your guess at the words \
53 a FUTURE QUESTION would use. Write 4-8 of them: the topic word in the user's \
54 language even when this sentence never used it, its English equivalent, and \
55 the obvious synonyms. A user who says 'main vegetarian hoon' will later ask \
56 'mera khaana ka preference kya hai' or 'what do I eat', so this fact needs \
57 khaana, khana, food, diet, eat — not just the words hoon and khata that \
58 happen to appear above.
59- Return an empty list when the utterance reveals nothing worth keeping. That \
60 is the common case.";
61
62#[derive(Debug, Clone)]
64pub struct ObservationExtractionContext {
65 pub transcript: String,
67 pub recent_user_turns: Vec<String>,
69 pub recent_assistant_turn: Option<String>,
71 pub known_predicates: Vec<String>,
82 pub speaker: SpeakerAttribution,
84 pub session_id: SessionId,
86 pub turn_id: TurnId,
88 pub now: DateTime<Utc>,
90}
91
92impl ObservationExtractionContext {
93 pub fn user_turn(
95 transcript: impl Into<String>,
96 session_id: SessionId,
97 turn_id: TurnId,
98 now: DateTime<Utc>,
99 ) -> Self {
100 Self {
101 transcript: transcript.into(),
102 recent_user_turns: Vec::new(),
103 recent_assistant_turn: None,
104 known_predicates: Vec::new(),
105 speaker: SpeakerAttribution::User,
106 session_id,
107 turn_id,
108 now,
109 }
110 }
111
112 pub fn attributed_to(mut self, speaker: SpeakerAttribution) -> Self {
114 self.speaker = speaker;
115 self
116 }
117
118 pub fn with_known_predicates(mut self, predicates: Vec<String>) -> Self {
120 self.known_predicates = predicates;
121 self
122 }
123}
124
125#[async_trait]
127pub trait MemoryObservationExtractor: Send + Sync {
128 async fn extract(
130 &self,
131 context: ObservationExtractionContext,
132 ) -> Result<Vec<MemoryObservation>, MemoryError>;
133}
134
135pub fn observation_schema() -> serde_json::Value {
137 let schema = schemars::schema_for!(MemoryObservation);
138 serde_json::to_value(schema).unwrap_or(serde_json::Value::Null)
139}
140
141pub struct BoundedObservationExtractor {
148 inner: Arc<dyn MemoryObservationExtractor>,
149 timeout: Duration,
150}
151
152impl BoundedObservationExtractor {
153 pub fn new(inner: Arc<dyn MemoryObservationExtractor>, timeout: Duration) -> Self {
155 Self { inner, timeout }
156 }
157}
158
159#[async_trait]
160impl MemoryObservationExtractor for BoundedObservationExtractor {
161 async fn extract(
162 &self,
163 context: ObservationExtractionContext,
164 ) -> Result<Vec<MemoryObservation>, MemoryError> {
165 match tokio::time::timeout(self.timeout, self.inner.extract(context)).await {
166 Ok(result) => result,
167 Err(_) => Err(MemoryError::DeadlineExceeded {
168 operation: "observation extraction",
169 budget_ms: self.timeout.as_millis() as u64,
170 }),
171 }
172 }
173}
174
175#[derive(Debug, Default)]
182pub struct RuleBasedObservationExtractor;
183
184impl RuleBasedObservationExtractor {
185 pub fn new() -> Self {
187 Self
188 }
189}
190
191const COMMANDS: &[(&str, MutationIntent)] = &[
194 ("what do you remember", MutationIntent::List),
195 ("what do you know about me", MutationIntent::List),
196 ("please remember that", MutationIntent::Remember),
197 ("please remember", MutationIntent::Remember),
198 ("remember that", MutationIntent::Remember),
199 ("do not forget that", MutationIntent::Remember),
200 ("dont forget that", MutationIntent::Remember),
201 ("do not forget", MutationIntent::Remember),
202 ("dont forget", MutationIntent::Remember),
203 ("from now on", MutationIntent::Remember),
204 ("delete everything about", MutationIntent::Delete),
205 ("delete what you know about", MutationIntent::Delete),
206 ("forget that", MutationIntent::Forget),
207 ("forget about", MutationIntent::Forget),
208 ("forget", MutationIntent::Forget),
209 ("i am no longer", MutationIntent::Correct),
210 ("im no longer", MutationIntent::Correct),
211 ("correct that", MutationIntent::Correct),
212 ("actually i am", MutationIntent::Correct),
213 ("actually im", MutationIntent::Correct),
214];
215
216const SELF_STATEMENTS: &[(&str, MemoryKind)] = &[
218 ("i am allergic to", MemoryKind::Identity),
219 ("im allergic to", MemoryKind::Identity),
220 ("i do not eat", MemoryKind::Preference),
221 ("i dont eat", MemoryKind::Preference),
222 ("i never eat", MemoryKind::Preference),
223 ("i always", MemoryKind::Routine),
224 ("i usually", MemoryKind::Routine),
225 ("i have started", MemoryKind::Routine),
226 ("ive started", MemoryKind::Routine),
227 ("i prefer", MemoryKind::Preference),
228 ("i love", MemoryKind::Preference),
229 ("i like", MemoryKind::Preference),
230 ("i hate", MemoryKind::Preference),
231 ("i work at", MemoryKind::Identity),
232 ("i live in", MemoryKind::Identity),
233 ("i am", MemoryKind::Identity),
234 ("im", MemoryKind::Identity),
235];
236
237const EPISODIC_MARKERS: &[&str] = &[
239 "tonight",
240 "today",
241 "tomorrow",
242 "this morning",
243 "this afternoon",
244 "this evening",
245 "last night",
246 "yesterday",
247 "this week",
248 "right now",
249 "at the moment",
250];
251
252const SENSITIVE_MARKERS: &[&str] = &[
254 "diagnosed",
255 "medication",
256 "therapy",
257 "depressed",
258 "anxiety",
259 "pregnant",
260 "church",
261 "mosque",
262 "temple",
263 "synagogue",
264 "voted",
265 "political",
266];
267
268fn normalize_utterance(raw: &str) -> String {
283 let elided: String = raw
284 .chars()
285 .filter(|c| !matches!(c, '\'' | '\u{2019}' | '\u{02BC}'))
286 .collect();
287 normalize_token(&elided)
288}
289
290#[async_trait]
291impl MemoryObservationExtractor for RuleBasedObservationExtractor {
292 async fn extract(
293 &self,
294 context: ObservationExtractionContext,
295 ) -> Result<Vec<MemoryObservation>, MemoryError> {
296 if !context.speaker.may_be_stored() {
299 return Ok(Vec::new());
300 }
301
302 let evidence = TranscriptEvidence::new(&context.transcript);
303 Ok(utterance_sentences(&context.transcript)
304 .into_iter()
305 .flat_map(|sentence| extract_sentence(&context, &evidence, sentence))
306 .collect())
307 }
308}
309
310fn utterance_sentences(raw: &str) -> Vec<&str> {
313 let mut sentences = Vec::new();
314 let mut start = 0;
315 let mut chars = raw.char_indices().peekable();
316 while let Some((index, ch)) = chars.next() {
317 let boundary = matches!(ch, '!' | '?' | ';' | '\n')
318 || (ch == '.' && chars.peek().is_none_or(|(_, next)| next.is_whitespace()));
319 if boundary {
320 sentences.push(&raw[start..index]);
321 start = index + ch.len_utf8();
322 }
323 }
324 sentences.push(&raw[start..]);
325 sentences
326}
327
328fn extract_sentence(
329 context: &ObservationExtractionContext,
330 evidence: &TranscriptEvidence,
331 sentence: &str,
332) -> Vec<MemoryObservation> {
333 let normalized = normalize_utterance(sentence);
334 let mut observations = Vec::new();
335
336 if let Some((phrase, intent)) = COMMANDS
337 .iter()
338 .find(|(phrase, _)| normalized.contains(phrase))
339 {
340 let raw_remainder = normalized
341 .split_once(phrase)
342 .map(|(_, rest)| rest.trim().to_string())
343 .unwrap_or_default();
344 let remainder = strip_self_reference(&raw_remainder);
348 observations.push(build(
349 context,
350 evidence,
351 command_kind(*intent),
352 CanonicalPredicate::new(command_predicate(*intent, &remainder)),
353 MemoryValue::Text(remainder.clone()),
354 command_statement(*intent, &remainder),
355 Explicitness::ExplicitCommand,
356 1.0,
357 ProposedPersistence::Durable,
358 TemporalScope::Persistent,
359 SensitivityClass::Normal,
360 Some(*intent),
361 ));
362 return observations;
363 }
364
365 for (opener, kind) in SELF_STATEMENTS
369 .iter()
370 .filter(|(opener, _)| starts_clause(&normalized, opener))
371 {
372 let value = clause_after(&normalized, opener);
373 if value.is_empty() {
374 continue;
375 }
376
377 let episodic = EPISODIC_MARKERS.iter().any(|m| normalized.contains(m));
378 let (kind, scope, persistence) = if episodic {
379 (
380 MemoryKind::Episodic,
381 TemporalScope::Momentary,
382 ProposedPersistence::Episodic,
383 )
384 } else {
385 (
386 *kind,
387 TemporalScope::Persistent,
388 ProposedPersistence::Durable,
389 )
390 };
391
392 let sensitivity = if SENSITIVE_MARKERS.iter().any(|m| normalized.contains(m)) {
393 SensitivityClass::Sensitive
394 } else {
395 SensitivityClass::Normal
396 };
397
398 if observations.iter().any(|o: &MemoryObservation| {
403 let existing = o.value.display();
404 existing.contains(&value) || value.contains(&existing)
405 }) {
406 continue;
407 }
408 let predicate = CanonicalPredicate::new(predicate_for(opener, &value));
409 if observations
410 .iter()
411 .any(|o: &MemoryObservation| o.predicate == predicate)
412 {
413 continue;
414 }
415 observations.push(build(
416 context,
417 evidence,
418 kind,
419 predicate,
420 MemoryValue::Text(value.clone()),
421 statement_for(opener, &value),
422 Explicitness::ExplicitStatement,
423 0.9,
424 persistence,
425 scope,
426 sensitivity,
427 None,
428 ));
429 }
430
431 observations
432}
433
434fn clause_after(text: &str, opener: &str) -> String {
439 const SEPARATORS: [&str; 4] = [" and ", " but ", " so ", " because "];
440 let Some((_, rest)) = text.split_once(opener) else {
441 return String::new();
442 };
443 let mut clause = rest.trim();
444 for separator in SEPARATORS {
445 if let Some((head, _)) = clause.split_once(separator) {
446 clause = head.trim();
447 }
448 }
449 clause.to_string()
450}
451
452fn starts_clause(text: &str, opener: &str) -> bool {
457 if text.starts_with(opener) {
458 return true;
459 }
460 for separator in [" and ", " but ", " so ", " because "] {
461 if text.contains(&format!("{separator}{opener} ")) {
462 return true;
463 }
464 }
465 false
466}
467
468#[allow(clippy::too_many_arguments, reason = "an internal record constructor")]
469fn build(
470 context: &ObservationExtractionContext,
471 evidence: &TranscriptEvidence,
472 kind: MemoryKind,
473 predicate: CanonicalPredicate,
474 value: MemoryValue,
475 statement: String,
476 explicitness: Explicitness,
477 confidence: f32,
478 persistence: ProposedPersistence,
479 temporal_scope: TemporalScope,
480 sensitivity: SensitivityClass,
481 mutation_intent: Option<MutationIntent>,
482) -> MemoryObservation {
483 let expected_expiry =
484 crate::core::default_episodic_ttl(kind, temporal_scope).map(|ttl| context.now + ttl);
485 MemoryObservation {
486 observation_id: ObservationId::generate(),
487 session_id: context.session_id.clone(),
488 turn_id: context.turn_id,
489 subject: EntityRef::user(),
490 predicate,
491 value,
492 canonical_statement: statement,
493 kind,
494 explicitness,
495 confidence,
496 persistence,
497 temporal_scope,
498 valid_from: Some(context.now),
499 expected_expiry,
500 transcript_evidence: evidence.clone(),
501 speaker_attribution: context.speaker,
502 sensitivity,
503 mutation_intent,
504 search_terms: Vec::new(),
505 }
506}
507
508fn command_kind(intent: MutationIntent) -> MemoryKind {
509 match intent {
510 MutationIntent::Forget | MutationIntent::Delete | MutationIntent::List => {
511 MemoryKind::Identity
512 }
513 _ => MemoryKind::Preference,
514 }
515}
516
517fn command_predicate(intent: MutationIntent, remainder: &str) -> String {
518 match intent {
519 MutationIntent::Forget | MutationIntent::Delete => "memory_removal".to_string(),
520 MutationIntent::List => "memory_listing".to_string(),
521 _ => predicate_for("", remainder),
522 }
523}
524
525fn command_statement(intent: MutationIntent, remainder: &str) -> String {
526 match intent {
527 MutationIntent::Forget => format!("The user asked to forget: {remainder}."),
528 MutationIntent::Delete => format!("The user asked to delete everything about {remainder}."),
529 MutationIntent::List => "The user asked what is remembered about them.".to_string(),
530 MutationIntent::Correct | MutationIntent::Remember => statement_for("", remainder),
534 }
535}
536
537fn strip_self_reference(remainder: &str) -> String {
543 let mut value = remainder.trim();
544 for prefix in ["that i am ", "that im ", "that i ", "i am ", "im ", "that "] {
545 if let Some(stripped) = value.strip_prefix(prefix) {
546 value = stripped.trim();
547 break;
548 }
549 }
550 for suffix in [" from now on", " anymore", " any more", " now"] {
551 if let Some(stripped) = value.strip_suffix(suffix) {
552 value = stripped.trim();
553 break;
554 }
555 }
556 value.to_string()
557}
558
559fn predicate_for(opener: &str, value: &str) -> String {
561 const TOPICS: &[(&str, &str)] = &[
562 ("vegetarian", "dietary_identity"),
563 ("vegan", "dietary_identity"),
564 ("pescatarian", "dietary_identity"),
565 ("meat", "dietary_identity"),
566 ("fish", "dietary_identity"),
567 ("allergic", "allergy"),
568 ("coffee", "beverage_preference"),
569 ("tea", "beverage_preference"),
570 ("gym", "exercise_routine"),
571 ("run", "exercise_routine"),
572 ("workout", "exercise_routine"),
573 ("restaurant", "venue_preference"),
574 ("music", "music_preference"),
575 ];
576 if let Some((_, predicate)) = TOPICS.iter().find(|(topic, _)| value.contains(topic)) {
577 return (*predicate).to_string();
578 }
579 match opener {
580 "i live in" => "residence".to_string(),
581 "i work at" => "employer".to_string(),
582 "i always" | "i usually" | "i have started" | "ive started" => "routine".to_string(),
583 _ => "preference".to_string(),
584 }
585}
586
587fn statement_for(opener: &str, value: &str) -> String {
588 let subject = match opener {
589 "i do not eat" | "i dont eat" | "i never eat" => "The user does not eat",
590 "i prefer" => "The user prefers",
591 "i love" => "The user loves",
592 "i like" => "The user likes",
593 "i hate" => "The user dislikes",
594 "i work at" => "The user works at",
595 "i live in" => "The user lives in",
596 "i always" => "The user always",
597 "i usually" => "The user usually",
598 "i have started" | "ive started" => "The user has started",
599 "i am allergic to" | "im allergic to" => "The user is allergic to",
600 _ => "The user is",
601 };
602 format!("{subject} {value}.")
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 async fn extract(text: &str) -> Vec<MemoryObservation> {
610 RuleBasedObservationExtractor::new()
611 .extract(ObservationExtractionContext::user_turn(
612 text,
613 SessionId::new("ses_1"),
614 TurnId(1),
615 Utc::now(),
616 ))
617 .await
618 .unwrap()
619 }
620
621 #[tokio::test]
622 async fn an_explicit_preference_becomes_a_durable_candidate() {
623 let observations = extract("I am pescatarian").await;
624 assert_eq!(observations.len(), 1);
625 let obs = &observations[0];
626 assert_eq!(obs.predicate.as_str(), "dietary_identity");
627 assert_eq!(obs.explicitness, Explicitness::ExplicitStatement);
628 assert_eq!(obs.persistence, ProposedPersistence::Durable);
629 assert_eq!(obs.canonical_statement, "The user is pescatarian.");
630 }
631
632 #[tokio::test]
633 async fn a_memory_command_carries_its_intent() {
634 let observations = extract("Please remember that I am pescatarian now").await;
635 assert_eq!(observations.len(), 1);
636 assert_eq!(
637 observations[0].mutation_intent,
638 Some(MutationIntent::Remember)
639 );
640 assert_eq!(observations[0].explicitness, Explicitness::ExplicitCommand);
641 assert_eq!(observations[0].confidence, 1.0);
642 }
643
644 #[tokio::test]
645 async fn a_command_stores_the_fact_rather_than_the_act_of_commanding() {
646 let commanded = extract("please remember that I am pescatarian now").await;
647 assert_eq!(commanded[0].canonical_statement, "The user is pescatarian.");
648 assert_eq!(commanded[0].predicate.as_str(), "dietary_identity");
649
650 let stated = extract("I am pescatarian").await;
653 assert_eq!(commanded[0].fingerprint(), stated[0].fingerprint());
654 }
655
656 #[tokio::test]
657 async fn a_correction_reads_as_the_corrected_fact() {
658 let observations = extract("actually I am pescatarian").await;
659 assert_eq!(
660 observations[0].mutation_intent,
661 Some(MutationIntent::Correct)
662 );
663 assert_eq!(
664 observations[0].canonical_statement,
665 "The user is pescatarian."
666 );
667 }
668
669 #[tokio::test]
670 async fn forget_and_delete_are_distinguished_from_remember() {
671 assert_eq!(
672 extract("forget that I like sushi").await[0].mutation_intent,
673 Some(MutationIntent::Forget)
674 );
675 assert_eq!(
676 extract("delete everything about my old job").await[0].mutation_intent,
677 Some(MutationIntent::Delete)
678 );
679 assert_eq!(
681 extract("dont forget that I am allergic to nuts").await[0].mutation_intent,
682 Some(MutationIntent::Remember)
683 );
684 }
685
686 #[tokio::test]
687 async fn a_time_bounded_statement_becomes_episodic_with_an_expiry() {
688 let observations = extract("I am meeting Kushal for dinner tonight").await;
689 assert_eq!(observations.len(), 1);
690 assert_eq!(observations[0].kind, MemoryKind::Episodic);
691 assert_eq!(observations[0].persistence, ProposedPersistence::Episodic);
692 assert!(observations[0].expected_expiry.is_some());
693 }
694
695 #[tokio::test]
696 async fn bystander_and_assistant_speech_yields_nothing() {
697 for speaker in [
698 SpeakerAttribution::Bystander,
699 SpeakerAttribution::Assistant,
700 SpeakerAttribution::Unknown,
701 ] {
702 let observations = RuleBasedObservationExtractor::new()
703 .extract(
704 ObservationExtractionContext::user_turn(
705 "I am pescatarian",
706 SessionId::new("ses_1"),
707 TurnId(1),
708 Utc::now(),
709 )
710 .attributed_to(speaker),
711 )
712 .await
713 .unwrap();
714 assert!(observations.is_empty(), "{speaker:?} produced candidates");
715 }
716 }
717
718 #[tokio::test]
719 async fn an_ordinary_utterance_reveals_nothing_worth_keeping() {
720 assert!(extract("what is the weather like").await.is_empty());
721 assert!(extract("okay thanks").await.is_empty());
722 }
723
724 #[tokio::test]
725 async fn a_first_person_phrase_inside_a_question_is_not_a_statement() {
726 assert!(
728 extract("what i am asking is whether it is open")
729 .await
730 .is_empty()
731 );
732 }
733
734 #[tokio::test]
735 async fn a_contracted_utterance_carries_the_same_fact_as_its_expanded_form() {
736 for (contracted, expanded) in [
743 ("I'm allergic to sesame", "I am allergic to sesame"),
744 (
745 "I've started swimming on Tuesdays",
746 "I have started swimming on Tuesdays",
747 ),
748 ] {
749 let (short, long) = (extract(contracted).await, extract(expanded).await);
750 assert!(
751 !short.is_empty(),
752 "`{contracted}` produced no observation while `{expanded}` did"
753 );
754 assert_eq!(
755 short[0].predicate, long[0].predicate,
756 "`{contracted}` and `{expanded}` are the same fact and must land on \
757 the same predicate, or a correction will not supersede what it corrects"
758 );
759 assert_eq!(
760 short[0].value, long[0].value,
761 "`{contracted}` and `{expanded}` must carry the same value, or they \
762 fingerprint differently and reinforce nothing"
763 );
764 }
765 }
766
767 #[tokio::test]
768 async fn statements_after_a_conjunction_are_still_recognised() {
769 let observations = extract("we went out and i do not eat meat").await;
770 assert_eq!(observations.len(), 1);
771 assert_eq!(observations[0].predicate.as_str(), "dietary_identity");
772 }
773
774 #[tokio::test]
775 async fn one_utterance_carrying_two_facts_yields_two_observations() {
776 let observations = extract("I am pescatarian and I prefer quiet places").await;
777 let predicates: Vec<&str> = observations.iter().map(|o| o.predicate.as_str()).collect();
778 assert!(
779 predicates.contains(&"dietary_identity"),
780 "the dietary fact was dropped: {predicates:?}"
781 );
782 assert!(
783 predicates.contains(&"preference") || predicates.contains(&"venue_preference"),
784 "the venue preference was dropped: {predicates:?}"
785 );
786 }
787
788 #[tokio::test]
789 async fn overlapping_openers_yield_one_fact_not_two() {
790 let observations = extract("I am allergic to nuts").await;
791 assert_eq!(
792 observations.len(),
793 1,
794 "the same clause was captured twice: {:?}",
795 observations
796 .iter()
797 .map(|o| (o.predicate.to_string(), o.value.display()))
798 .collect::<Vec<_>>()
799 );
800 }
801
802 #[tokio::test]
803 async fn a_clause_value_stops_at_the_conjunction() {
804 let observations = extract("I am pescatarian and I prefer quiet places").await;
805 let dietary = observations
806 .iter()
807 .find(|o| o.predicate.as_str() == "dietary_identity")
808 .expect("dietary fact");
809 assert_eq!(
810 dietary.canonical_statement, "The user is pescatarian.",
811 "the value swallowed the following clause"
812 );
813 }
814
815 #[tokio::test]
816 async fn a_dietary_value_does_not_absorb_a_following_sales_sentence() {
817 let observations =
818 extract("I am pescatarian. I am calling to sell your office a software subscription.")
819 .await;
820 let dietary = observations
821 .iter()
822 .find(|observation| observation.predicate.as_str() == "dietary_identity")
823 .unwrap();
824 assert_eq!(dietary.value.display(), "pescatarian");
825 assert_eq!(dietary.canonical_statement, "The user is pescatarian.");
826 }
827
828 #[tokio::test]
829 async fn separate_sentences_keep_their_facts_and_temporal_scope() {
830 let observations =
831 extract("I'm pescatarian. I prefer quiet places! I am meeting friends tonight.").await;
832 let dietary = observations
833 .iter()
834 .find(|observation| observation.predicate.as_str() == "dietary_identity")
835 .unwrap();
836 assert_eq!(dietary.value.display(), "pescatarian");
837 assert_eq!(dietary.temporal_scope, TemporalScope::Persistent);
838 assert!(
839 observations
840 .iter()
841 .any(|observation| observation.value.display() == "quiet places")
842 );
843 assert!(
844 observations
845 .iter()
846 .any(|observation| observation.temporal_scope == TemporalScope::Momentary)
847 );
848 let commanded = extract("Remember that I am pescatarian. I prefer quiet places.").await;
849 assert_eq!(commanded[0].value.display(), "pescatarian");
850 assert!(
851 commanded
852 .iter()
853 .any(|observation| observation.value.display() == "quiet places")
854 );
855 }
856
857 #[test]
858 fn sentence_boundaries_preserve_decimal_and_domain_punctuation() {
859 assert_eq!(
860 utterance_sentences("I work at example.com. I prefer 1.5 servings."),
861 vec!["I work at example.com", " I prefer 1.5 servings", ""]
862 );
863 }
864
865 struct Hangs;
866
867 #[async_trait]
868 impl MemoryObservationExtractor for Hangs {
869 async fn extract(
870 &self,
871 _context: ObservationExtractionContext,
872 ) -> Result<Vec<MemoryObservation>, MemoryError> {
873 tokio::time::sleep(Duration::from_secs(30)).await;
874 unreachable!("the bound should fire first")
875 }
876 }
877
878 #[tokio::test]
879 async fn a_hanging_extractor_reports_a_retryable_deadline_rather_than_blocking() {
880 let bounded = BoundedObservationExtractor::new(Arc::new(Hangs), Duration::from_millis(20));
881 let err = bounded
882 .extract(ObservationExtractionContext::user_turn(
883 "I am pescatarian",
884 SessionId::new("ses_1"),
885 TurnId(1),
886 Utc::now(),
887 ))
888 .await
889 .unwrap_err();
890 assert!(err.is_retryable());
891 }
892
893 #[tokio::test]
894 async fn a_health_statement_is_classified_sensitive() {
895 let observations = extract("I am diagnosed with a heart condition").await;
896 assert_eq!(observations.len(), 1);
897 assert_eq!(observations[0].sensitivity, SensitivityClass::Sensitive);
898 assert!(observations[0].explicitness.is_explicit());
901 }
902
903 #[test]
904 fn the_instruction_states_the_attribution_and_sensitivity_rules() {
905 assert!(OBSERVATION_EXTRACTION_INSTRUCTION.contains("Only extract things the USER said"));
906 assert!(OBSERVATION_EXTRACTION_INSTRUCTION.contains("Never infer sensitive attributes"));
907 }
908
909 #[test]
910 fn the_observation_schema_is_a_json_object_schema() {
911 let schema = observation_schema();
912 assert_eq!(schema["type"], "object");
913 assert!(schema["properties"]["explicitness"].is_object());
914 }
915}