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 normalized = normalize_utterance(&context.transcript);
303 let evidence = TranscriptEvidence::new(&context.transcript);
304 let mut observations = Vec::new();
305
306 if let Some((phrase, intent)) = COMMANDS
307 .iter()
308 .find(|(phrase, _)| normalized.contains(phrase))
309 {
310 let raw_remainder = normalized
311 .split_once(phrase)
312 .map(|(_, rest)| rest.trim().to_string())
313 .unwrap_or_default();
314 let remainder = strip_self_reference(&raw_remainder);
318 observations.push(build(
319 &context,
320 &evidence,
321 command_kind(*intent),
322 CanonicalPredicate::new(command_predicate(*intent, &remainder)),
323 MemoryValue::Text(remainder.clone()),
324 command_statement(*intent, &remainder),
325 Explicitness::ExplicitCommand,
326 1.0,
327 ProposedPersistence::Durable,
328 TemporalScope::Persistent,
329 SensitivityClass::Normal,
330 Some(*intent),
331 ));
332 return Ok(observations);
333 }
334
335 for (opener, kind) in SELF_STATEMENTS
339 .iter()
340 .filter(|(opener, _)| starts_clause(&normalized, opener))
341 {
342 let value = clause_after(&normalized, opener);
343 if value.is_empty() {
344 continue;
345 }
346
347 let episodic = EPISODIC_MARKERS.iter().any(|m| normalized.contains(m));
348 let (kind, scope, persistence) = if episodic {
349 (
350 MemoryKind::Episodic,
351 TemporalScope::Momentary,
352 ProposedPersistence::Episodic,
353 )
354 } else {
355 (
356 *kind,
357 TemporalScope::Persistent,
358 ProposedPersistence::Durable,
359 )
360 };
361
362 let sensitivity = if SENSITIVE_MARKERS.iter().any(|m| normalized.contains(m)) {
363 SensitivityClass::Sensitive
364 } else {
365 SensitivityClass::Normal
366 };
367
368 if observations.iter().any(|o: &MemoryObservation| {
373 let existing = o.value.display();
374 existing.contains(&value) || value.contains(&existing)
375 }) {
376 continue;
377 }
378 let predicate = CanonicalPredicate::new(predicate_for(opener, &value));
379 if observations
380 .iter()
381 .any(|o: &MemoryObservation| o.predicate == predicate)
382 {
383 continue;
384 }
385 observations.push(build(
386 &context,
387 &evidence,
388 kind,
389 predicate,
390 MemoryValue::Text(value.clone()),
391 statement_for(opener, &value),
392 Explicitness::ExplicitStatement,
393 0.9,
394 persistence,
395 scope,
396 sensitivity,
397 None,
398 ));
399 }
400
401 Ok(observations)
402 }
403}
404
405fn clause_after(text: &str, opener: &str) -> String {
410 const SEPARATORS: [&str; 4] = [" and ", " but ", " so ", " because "];
411 let Some((_, rest)) = text.split_once(opener) else {
412 return String::new();
413 };
414 let mut clause = rest.trim();
415 for separator in SEPARATORS {
416 if let Some((head, _)) = clause.split_once(separator) {
417 clause = head.trim();
418 }
419 }
420 clause.to_string()
421}
422
423fn starts_clause(text: &str, opener: &str) -> bool {
428 if text.starts_with(opener) {
429 return true;
430 }
431 for separator in [" and ", " but ", " so ", " because "] {
432 if text.contains(&format!("{separator}{opener} ")) {
433 return true;
434 }
435 }
436 false
437}
438
439#[allow(clippy::too_many_arguments, reason = "an internal record constructor")]
440fn build(
441 context: &ObservationExtractionContext,
442 evidence: &TranscriptEvidence,
443 kind: MemoryKind,
444 predicate: CanonicalPredicate,
445 value: MemoryValue,
446 statement: String,
447 explicitness: Explicitness,
448 confidence: f32,
449 persistence: ProposedPersistence,
450 temporal_scope: TemporalScope,
451 sensitivity: SensitivityClass,
452 mutation_intent: Option<MutationIntent>,
453) -> MemoryObservation {
454 let expected_expiry =
455 crate::core::default_episodic_ttl(kind, temporal_scope).map(|ttl| context.now + ttl);
456 MemoryObservation {
457 observation_id: ObservationId::generate(),
458 session_id: context.session_id.clone(),
459 turn_id: context.turn_id,
460 subject: EntityRef::user(),
461 predicate,
462 value,
463 canonical_statement: statement,
464 kind,
465 explicitness,
466 confidence,
467 persistence,
468 temporal_scope,
469 valid_from: Some(context.now),
470 expected_expiry,
471 transcript_evidence: evidence.clone(),
472 speaker_attribution: context.speaker,
473 sensitivity,
474 mutation_intent,
475 search_terms: Vec::new(),
476 }
477}
478
479fn command_kind(intent: MutationIntent) -> MemoryKind {
480 match intent {
481 MutationIntent::Forget | MutationIntent::Delete | MutationIntent::List => {
482 MemoryKind::Identity
483 }
484 _ => MemoryKind::Preference,
485 }
486}
487
488fn command_predicate(intent: MutationIntent, remainder: &str) -> String {
489 match intent {
490 MutationIntent::Forget | MutationIntent::Delete => "memory_removal".to_string(),
491 MutationIntent::List => "memory_listing".to_string(),
492 _ => predicate_for("", remainder),
493 }
494}
495
496fn command_statement(intent: MutationIntent, remainder: &str) -> String {
497 match intent {
498 MutationIntent::Forget => format!("The user asked to forget: {remainder}."),
499 MutationIntent::Delete => format!("The user asked to delete everything about {remainder}."),
500 MutationIntent::List => "The user asked what is remembered about them.".to_string(),
501 MutationIntent::Correct | MutationIntent::Remember => statement_for("", remainder),
505 }
506}
507
508fn strip_self_reference(remainder: &str) -> String {
514 let mut value = remainder.trim();
515 for prefix in ["that i am ", "that im ", "that i ", "i am ", "im ", "that "] {
516 if let Some(stripped) = value.strip_prefix(prefix) {
517 value = stripped.trim();
518 break;
519 }
520 }
521 for suffix in [" from now on", " anymore", " any more", " now"] {
522 if let Some(stripped) = value.strip_suffix(suffix) {
523 value = stripped.trim();
524 break;
525 }
526 }
527 value.to_string()
528}
529
530fn predicate_for(opener: &str, value: &str) -> String {
532 const TOPICS: &[(&str, &str)] = &[
533 ("vegetarian", "dietary_identity"),
534 ("vegan", "dietary_identity"),
535 ("pescatarian", "dietary_identity"),
536 ("meat", "dietary_identity"),
537 ("fish", "dietary_identity"),
538 ("allergic", "allergy"),
539 ("coffee", "beverage_preference"),
540 ("tea", "beverage_preference"),
541 ("gym", "exercise_routine"),
542 ("run", "exercise_routine"),
543 ("workout", "exercise_routine"),
544 ("restaurant", "venue_preference"),
545 ("music", "music_preference"),
546 ];
547 if let Some((_, predicate)) = TOPICS.iter().find(|(topic, _)| value.contains(topic)) {
548 return (*predicate).to_string();
549 }
550 match opener {
551 "i live in" => "residence".to_string(),
552 "i work at" => "employer".to_string(),
553 "i always" | "i usually" | "i have started" | "ive started" => "routine".to_string(),
554 _ => "preference".to_string(),
555 }
556}
557
558fn statement_for(opener: &str, value: &str) -> String {
559 let subject = match opener {
560 "i do not eat" | "i dont eat" | "i never eat" => "The user does not eat",
561 "i prefer" => "The user prefers",
562 "i love" => "The user loves",
563 "i like" => "The user likes",
564 "i hate" => "The user dislikes",
565 "i work at" => "The user works at",
566 "i live in" => "The user lives in",
567 "i always" => "The user always",
568 "i usually" => "The user usually",
569 "i have started" | "ive started" => "The user has started",
570 "i am allergic to" | "im allergic to" => "The user is allergic to",
571 _ => "The user is",
572 };
573 format!("{subject} {value}.")
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 async fn extract(text: &str) -> Vec<MemoryObservation> {
581 RuleBasedObservationExtractor::new()
582 .extract(ObservationExtractionContext::user_turn(
583 text,
584 SessionId::new("ses_1"),
585 TurnId(1),
586 Utc::now(),
587 ))
588 .await
589 .unwrap()
590 }
591
592 #[tokio::test]
593 async fn an_explicit_preference_becomes_a_durable_candidate() {
594 let observations = extract("I am pescatarian").await;
595 assert_eq!(observations.len(), 1);
596 let obs = &observations[0];
597 assert_eq!(obs.predicate.as_str(), "dietary_identity");
598 assert_eq!(obs.explicitness, Explicitness::ExplicitStatement);
599 assert_eq!(obs.persistence, ProposedPersistence::Durable);
600 assert_eq!(obs.canonical_statement, "The user is pescatarian.");
601 }
602
603 #[tokio::test]
604 async fn a_memory_command_carries_its_intent() {
605 let observations = extract("Please remember that I am pescatarian now").await;
606 assert_eq!(observations.len(), 1);
607 assert_eq!(
608 observations[0].mutation_intent,
609 Some(MutationIntent::Remember)
610 );
611 assert_eq!(observations[0].explicitness, Explicitness::ExplicitCommand);
612 assert_eq!(observations[0].confidence, 1.0);
613 }
614
615 #[tokio::test]
616 async fn a_command_stores_the_fact_rather_than_the_act_of_commanding() {
617 let commanded = extract("please remember that I am pescatarian now").await;
618 assert_eq!(commanded[0].canonical_statement, "The user is pescatarian.");
619 assert_eq!(commanded[0].predicate.as_str(), "dietary_identity");
620
621 let stated = extract("I am pescatarian").await;
624 assert_eq!(commanded[0].fingerprint(), stated[0].fingerprint());
625 }
626
627 #[tokio::test]
628 async fn a_correction_reads_as_the_corrected_fact() {
629 let observations = extract("actually I am pescatarian").await;
630 assert_eq!(
631 observations[0].mutation_intent,
632 Some(MutationIntent::Correct)
633 );
634 assert_eq!(
635 observations[0].canonical_statement,
636 "The user is pescatarian."
637 );
638 }
639
640 #[tokio::test]
641 async fn forget_and_delete_are_distinguished_from_remember() {
642 assert_eq!(
643 extract("forget that I like sushi").await[0].mutation_intent,
644 Some(MutationIntent::Forget)
645 );
646 assert_eq!(
647 extract("delete everything about my old job").await[0].mutation_intent,
648 Some(MutationIntent::Delete)
649 );
650 assert_eq!(
652 extract("dont forget that I am allergic to nuts").await[0].mutation_intent,
653 Some(MutationIntent::Remember)
654 );
655 }
656
657 #[tokio::test]
658 async fn a_time_bounded_statement_becomes_episodic_with_an_expiry() {
659 let observations = extract("I am meeting Kushal for dinner tonight").await;
660 assert_eq!(observations.len(), 1);
661 assert_eq!(observations[0].kind, MemoryKind::Episodic);
662 assert_eq!(observations[0].persistence, ProposedPersistence::Episodic);
663 assert!(observations[0].expected_expiry.is_some());
664 }
665
666 #[tokio::test]
667 async fn bystander_and_assistant_speech_yields_nothing() {
668 for speaker in [
669 SpeakerAttribution::Bystander,
670 SpeakerAttribution::Assistant,
671 SpeakerAttribution::Unknown,
672 ] {
673 let observations = RuleBasedObservationExtractor::new()
674 .extract(
675 ObservationExtractionContext::user_turn(
676 "I am pescatarian",
677 SessionId::new("ses_1"),
678 TurnId(1),
679 Utc::now(),
680 )
681 .attributed_to(speaker),
682 )
683 .await
684 .unwrap();
685 assert!(observations.is_empty(), "{speaker:?} produced candidates");
686 }
687 }
688
689 #[tokio::test]
690 async fn an_ordinary_utterance_reveals_nothing_worth_keeping() {
691 assert!(extract("what is the weather like").await.is_empty());
692 assert!(extract("okay thanks").await.is_empty());
693 }
694
695 #[tokio::test]
696 async fn a_first_person_phrase_inside_a_question_is_not_a_statement() {
697 assert!(
699 extract("what i am asking is whether it is open")
700 .await
701 .is_empty()
702 );
703 }
704
705 #[tokio::test]
706 async fn a_contracted_utterance_carries_the_same_fact_as_its_expanded_form() {
707 for (contracted, expanded) in [
714 ("I'm allergic to sesame", "I am allergic to sesame"),
715 (
716 "I've started swimming on Tuesdays",
717 "I have started swimming on Tuesdays",
718 ),
719 ] {
720 let (short, long) = (extract(contracted).await, extract(expanded).await);
721 assert!(
722 !short.is_empty(),
723 "`{contracted}` produced no observation while `{expanded}` did"
724 );
725 assert_eq!(
726 short[0].predicate, long[0].predicate,
727 "`{contracted}` and `{expanded}` are the same fact and must land on \
728 the same predicate, or a correction will not supersede what it corrects"
729 );
730 assert_eq!(
731 short[0].value, long[0].value,
732 "`{contracted}` and `{expanded}` must carry the same value, or they \
733 fingerprint differently and reinforce nothing"
734 );
735 }
736 }
737
738 #[tokio::test]
739 async fn statements_after_a_conjunction_are_still_recognised() {
740 let observations = extract("we went out and i do not eat meat").await;
741 assert_eq!(observations.len(), 1);
742 assert_eq!(observations[0].predicate.as_str(), "dietary_identity");
743 }
744
745 #[tokio::test]
746 async fn one_utterance_carrying_two_facts_yields_two_observations() {
747 let observations = extract("I am pescatarian and I prefer quiet places").await;
748 let predicates: Vec<&str> = observations.iter().map(|o| o.predicate.as_str()).collect();
749 assert!(
750 predicates.contains(&"dietary_identity"),
751 "the dietary fact was dropped: {predicates:?}"
752 );
753 assert!(
754 predicates.contains(&"preference") || predicates.contains(&"venue_preference"),
755 "the venue preference was dropped: {predicates:?}"
756 );
757 }
758
759 #[tokio::test]
760 async fn overlapping_openers_yield_one_fact_not_two() {
761 let observations = extract("I am allergic to nuts").await;
762 assert_eq!(
763 observations.len(),
764 1,
765 "the same clause was captured twice: {:?}",
766 observations
767 .iter()
768 .map(|o| (o.predicate.to_string(), o.value.display()))
769 .collect::<Vec<_>>()
770 );
771 }
772
773 #[tokio::test]
774 async fn a_clause_value_stops_at_the_conjunction() {
775 let observations = extract("I am pescatarian and I prefer quiet places").await;
776 let dietary = observations
777 .iter()
778 .find(|o| o.predicate.as_str() == "dietary_identity")
779 .expect("dietary fact");
780 assert_eq!(
781 dietary.canonical_statement, "The user is pescatarian.",
782 "the value swallowed the following clause"
783 );
784 }
785
786 struct Hangs;
787
788 #[async_trait]
789 impl MemoryObservationExtractor for Hangs {
790 async fn extract(
791 &self,
792 _context: ObservationExtractionContext,
793 ) -> Result<Vec<MemoryObservation>, MemoryError> {
794 tokio::time::sleep(Duration::from_secs(30)).await;
795 unreachable!("the bound should fire first")
796 }
797 }
798
799 #[tokio::test]
800 async fn a_hanging_extractor_reports_a_retryable_deadline_rather_than_blocking() {
801 let bounded = BoundedObservationExtractor::new(Arc::new(Hangs), Duration::from_millis(20));
802 let err = bounded
803 .extract(ObservationExtractionContext::user_turn(
804 "I am pescatarian",
805 SessionId::new("ses_1"),
806 TurnId(1),
807 Utc::now(),
808 ))
809 .await
810 .unwrap_err();
811 assert!(err.is_retryable());
812 }
813
814 #[tokio::test]
815 async fn a_health_statement_is_classified_sensitive() {
816 let observations = extract("I am diagnosed with a heart condition").await;
817 assert_eq!(observations.len(), 1);
818 assert_eq!(observations[0].sensitivity, SensitivityClass::Sensitive);
819 assert!(observations[0].explicitness.is_explicit());
822 }
823
824 #[test]
825 fn the_instruction_states_the_attribution_and_sensitivity_rules() {
826 assert!(OBSERVATION_EXTRACTION_INSTRUCTION.contains("Only extract things the USER said"));
827 assert!(OBSERVATION_EXTRACTION_INSTRUCTION.contains("Never infer sensitive attributes"));
828 }
829
830 #[test]
831 fn the_observation_schema_is_a_json_object_schema() {
832 let schema = observation_schema();
833 assert_eq!(schema["type"], "object");
834 assert!(schema["properties"]["explicitness"].is_object());
835 }
836}