1use anyhow::Result;
15use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
16use rocksdb::{IteratorMode, DB};
17use rust_stemmers::{Algorithm, Stemmer};
18use serde::{Deserialize, Serialize};
19use std::collections::HashSet;
20use std::sync::Arc;
21
22use super::types::MemoryId;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum EventType {
27 Planned,
29 Occurred,
31 Historical,
33 Recurring,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct TemporalFact {
40 pub id: String,
42 pub entity: String,
44 pub event: String,
46 pub event_stems: Vec<String>,
48 pub event_type: EventType,
50 pub relative_time: Option<String>,
52 pub resolved_time: ResolvedTime,
54 pub source_memory_id: MemoryId,
56 pub conversation_date: DateTime<Utc>,
58 pub confidence: f32,
60 pub source_text: String,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub enum ResolvedTime {
67 ExactDate(NaiveDate),
69 MonthYear { month: u32, year: i32 },
71 Year(i32),
73 RelativeDescription(String),
75 Unknown,
77}
78
79impl ResolvedTime {
80 pub fn to_sortable_string(&self) -> String {
82 match self {
83 ResolvedTime::ExactDate(d) => d.format("%Y-%m-%d").to_string(),
84 ResolvedTime::MonthYear { month, year } => format!("{:04}-{:02}", year, month),
85 ResolvedTime::Year(y) => format!("{:04}", y),
86 ResolvedTime::RelativeDescription(s) => s.clone(),
87 ResolvedTime::Unknown => "unknown".to_string(),
88 }
89 }
90
91 pub fn matches_query(&self, query: &str) -> bool {
93 let query_lower = query.to_lowercase();
94 let self_str = self.to_sortable_string().to_lowercase();
95
96 if self_str.contains(&query_lower) || query_lower.contains(&self_str) {
98 return true;
99 }
100
101 match self {
103 ResolvedTime::MonthYear { month, year } => {
104 let month_name = month_name(*month).to_lowercase();
105 query_lower.contains(&month_name) && query_lower.contains(&year.to_string())
106 }
107 ResolvedTime::Year(y) => query_lower.contains(&y.to_string()),
108 _ => false,
109 }
110 }
111}
112
113pub struct TemporalFactStore {
115 db: Arc<DB>,
116}
117
118impl TemporalFactStore {
119 pub fn new(db: Arc<DB>) -> Self {
120 Self { db }
121 }
122
123 pub fn store(&self, user_id: &str, fact: &TemporalFact) -> Result<()> {
125 let key = format!("temporal_facts:{}:{}", user_id, fact.id);
127 let value = bincode::serde::encode_to_vec(fact, bincode::config::standard())?;
128 self.db.put(key.as_bytes(), &value)?;
129
130 let entity_key = format!(
132 "temporal_by_entity:{}:{}:{}",
133 user_id,
134 fact.entity.to_lowercase(),
135 fact.id
136 );
137 self.db.put(entity_key.as_bytes(), fact.id.as_bytes())?;
138
139 for stem in &fact.event_stems {
141 let event_key = format!("temporal_by_event:{}:{}:{}", user_id, stem, fact.id);
142 self.db.put(event_key.as_bytes(), fact.id.as_bytes())?;
143 }
144
145 Ok(())
146 }
147
148 pub fn store_batch(&self, user_id: &str, facts: &[TemporalFact]) -> Result<usize> {
150 let mut stored = 0;
151 for fact in facts {
152 if self.store(user_id, fact).is_ok() {
153 stored += 1;
154 }
155 }
156 Ok(stored)
157 }
158
159 pub fn get(&self, user_id: &str, fact_id: &str) -> Result<Option<TemporalFact>> {
161 let key = format!("temporal_facts:{}:{}", user_id, fact_id);
162 match self.db.get(key.as_bytes())? {
163 Some(data) => {
164 let (fact, _): (TemporalFact, _) =
165 bincode::serde::decode_from_slice(&data, bincode::config::standard())?;
166 Ok(Some(fact))
167 }
168 None => Ok(None),
169 }
170 }
171
172 pub fn find_by_entity(
174 &self,
175 user_id: &str,
176 entity: &str,
177 limit: usize,
178 ) -> Result<Vec<TemporalFact>> {
179 let prefix = format!("temporal_by_entity:{}:{}:", user_id, entity.to_lowercase());
180 self.find_by_prefix(&prefix, user_id, limit)
181 }
182
183 pub fn find_by_event(
185 &self,
186 user_id: &str,
187 event: &str,
188 limit: usize,
189 ) -> Result<Vec<TemporalFact>> {
190 let stemmer = Stemmer::create(Algorithm::English);
191 let stem = stemmer.stem(&event.to_lowercase()).to_string();
192 let prefix = format!("temporal_by_event:{}:{}:", user_id, stem);
193 self.find_by_prefix(&prefix, user_id, limit)
194 }
195
196 pub fn find_by_entity_and_event(
198 &self,
199 user_id: &str,
200 entity: &str,
201 event_keywords: &[&str],
202 event_type: Option<EventType>,
203 ) -> Result<Vec<TemporalFact>> {
204 let entity_facts = self.find_by_entity(user_id, entity, 100)?;
206
207 let stemmer = Stemmer::create(Algorithm::English);
209 let event_stems: HashSet<String> = event_keywords
210 .iter()
211 .map(|kw| stemmer.stem(&kw.to_lowercase()).to_string())
212 .collect();
213
214 let mut matching: Vec<TemporalFact> = entity_facts
215 .into_iter()
216 .filter(|f| {
217 let has_event_match = f.event_stems.iter().any(|s| event_stems.contains(s));
219 let type_matches = event_type.map_or(true, |t| f.event_type == t);
221 has_event_match && type_matches
222 })
223 .collect();
224
225 matching.sort_by_key(|f| f.conversation_date);
227
228 Ok(matching)
229 }
230
231 fn find_by_prefix(
232 &self,
233 prefix: &str,
234 user_id: &str,
235 limit: usize,
236 ) -> Result<Vec<TemporalFact>> {
237 let mut facts = Vec::new();
238 let mut seen_ids = HashSet::new();
239
240 let iter = self.db.iterator(IteratorMode::From(
241 prefix.as_bytes(),
242 rocksdb::Direction::Forward,
243 ));
244
245 for item in iter {
246 let (key, value) = item?;
247 let key_str = String::from_utf8_lossy(&key);
248
249 if !key_str.starts_with(prefix) {
250 break;
251 }
252
253 let fact_id = String::from_utf8_lossy(&value);
254 if seen_ids.insert(fact_id.to_string()) {
255 if let Some(fact) = self.get(user_id, &fact_id)? {
256 facts.push(fact);
257 if facts.len() >= limit {
258 break;
259 }
260 }
261 }
262 }
263
264 Ok(facts)
265 }
266
267 pub fn list(&self, user_id: &str, limit: usize) -> Result<Vec<TemporalFact>> {
269 let prefix = format!("temporal_facts:{}:", user_id);
270 let mut facts = Vec::new();
271
272 let iter = self.db.iterator(IteratorMode::From(
273 prefix.as_bytes(),
274 rocksdb::Direction::Forward,
275 ));
276
277 for item in iter {
278 let (key, value) = item?;
279 let key_str = String::from_utf8_lossy(&key);
280
281 if !key_str.starts_with(&prefix) {
282 break;
283 }
284
285 if let Ok((fact, _)) = bincode::serde::decode_from_slice::<TemporalFact, _>(
286 &value,
287 bincode::config::standard(),
288 ) {
289 facts.push(fact);
290 if facts.len() >= limit {
291 break;
292 }
293 }
294 }
295
296 Ok(facts)
297 }
298}
299
300pub fn extract_temporal_facts(
306 content: &str,
307 memory_id: &MemoryId,
308 conversation_date: DateTime<Utc>,
309 entities: &[String],
310) -> Vec<TemporalFact> {
311 let mut facts = Vec::new();
312 let stemmer = Stemmer::create(Algorithm::English);
313
314 let sentences: Vec<&str> = content
316 .split(|c| c == '.' || c == '!' || c == '?')
317 .filter(|s| !s.trim().is_empty())
318 .collect();
319
320 for sentence in sentences {
321 let sentence_lower = sentence.to_lowercase();
322
323 let mentioned_entity = entities
325 .iter()
326 .find(|e| sentence_lower.contains(&e.to_lowercase()));
327
328 if mentioned_entity.is_none() {
329 continue;
330 }
331 let entity = mentioned_entity.unwrap().clone();
332
333 if let Some(fact) =
335 extract_planning_fact(sentence, &entity, memory_id, conversation_date, &stemmer)
336 {
337 facts.push(fact);
338 } else if let Some(fact) =
339 extract_occurred_fact(sentence, &entity, memory_id, conversation_date, &stemmer)
340 {
341 facts.push(fact);
342 } else if let Some(fact) =
343 extract_historical_fact(sentence, &entity, memory_id, conversation_date, &stemmer)
344 {
345 facts.push(fact);
346 }
347 }
348
349 facts
350}
351
352fn extract_planning_fact(
354 sentence: &str,
355 entity: &str,
356 memory_id: &MemoryId,
357 conversation_date: DateTime<Utc>,
358 stemmer: &Stemmer,
359) -> Option<TemporalFact> {
360 let sentence_lower = sentence.to_lowercase();
361
362 let planning_patterns = [
364 "planning to",
365 "planning on",
366 "going to",
367 "thinking about",
368 "want to",
369 "hope to",
370 "looking forward to",
371 "excited to",
372 "next month",
373 "next week",
374 "this weekend",
375 "soon",
376 ];
377
378 let has_planning = planning_patterns.iter().any(|p| sentence_lower.contains(p));
379 if !has_planning {
380 return None;
381 }
382
383 let (event, relative_time) = extract_event_and_time(sentence, &planning_patterns);
385 if event.is_empty() {
386 return None;
387 }
388
389 let resolved_time = resolve_relative_time(&relative_time, conversation_date);
391
392 let event_stems: Vec<String> = event
393 .split_whitespace()
394 .filter(|w| w.len() > 2)
395 .map(|w| stemmer.stem(&w.to_lowercase()).to_string())
396 .collect();
397
398 Some(TemporalFact {
399 id: format!("tf-{}-{}", memory_id.0, uuid::Uuid::new_v4()),
400 entity: entity.to_string(),
401 event: event.clone(),
402 event_stems,
403 event_type: EventType::Planned,
404 relative_time: if relative_time.is_empty() {
405 None
406 } else {
407 Some(relative_time)
408 },
409 resolved_time,
410 source_memory_id: memory_id.clone(),
411 conversation_date,
412 confidence: 0.8,
413 source_text: sentence.to_string(),
414 })
415}
416
417fn extract_occurred_fact(
419 sentence: &str,
420 entity: &str,
421 memory_id: &MemoryId,
422 conversation_date: DateTime<Utc>,
423 stemmer: &Stemmer,
424) -> Option<TemporalFact> {
425 let sentence_lower = sentence.to_lowercase();
426
427 let occurred_patterns = [
429 "last saturday",
430 "last sunday",
431 "last monday",
432 "last tuesday",
433 "last wednesday",
434 "last thursday",
435 "last friday",
436 "last week",
437 "last weekend",
438 "last month",
439 "yesterday",
440 "this morning",
441 "earlier today",
442 "recently",
443 "just",
444 "ago",
445 "two weeks ago",
446 "a few days ago",
447 ];
448
449 let past_verbs = [
451 "ran", "went", "did", "took", "made", "had", "got", "saw", "met",
452 ];
453 let has_past_event = occurred_patterns.iter().any(|p| sentence_lower.contains(p))
454 || past_verbs.iter().any(|v| sentence_lower.contains(v));
455
456 if !has_past_event {
457 return None;
458 }
459
460 let (event, relative_time) = extract_event_and_time(sentence, &occurred_patterns);
462 if event.is_empty() {
463 return None;
464 }
465
466 let resolved_time = resolve_relative_time(&relative_time, conversation_date);
468
469 let event_stems: Vec<String> = event
470 .split_whitespace()
471 .filter(|w| w.len() > 2)
472 .map(|w| stemmer.stem(&w.to_lowercase()).to_string())
473 .collect();
474
475 Some(TemporalFact {
476 id: format!("tf-{}-{}", memory_id.0, uuid::Uuid::new_v4()),
477 entity: entity.to_string(),
478 event: event.clone(),
479 event_stems,
480 event_type: EventType::Occurred,
481 relative_time: if relative_time.is_empty() {
482 None
483 } else {
484 Some(relative_time)
485 },
486 resolved_time,
487 source_memory_id: memory_id.clone(),
488 conversation_date,
489 confidence: 0.75,
490 source_text: sentence.to_string(),
491 })
492}
493
494fn extract_historical_fact(
496 sentence: &str,
497 entity: &str,
498 memory_id: &MemoryId,
499 conversation_date: DateTime<Utc>,
500 stemmer: &Stemmer,
501) -> Option<TemporalFact> {
502 let sentence_lower = sentence.to_lowercase();
503
504 let year_pattern = regex::Regex::new(r"\b(19|20)\d{2}\b").ok()?;
506 let year_match = year_pattern.find(&sentence_lower)?;
507 let year: i32 = year_match.as_str().parse().ok()?;
508
509 if year >= conversation_date.year() {
511 return None;
512 }
513
514 let event = extract_event_from_sentence(sentence);
516 if event.is_empty() {
517 return None;
518 }
519
520 let event_stems: Vec<String> = event
521 .split_whitespace()
522 .filter(|w| w.len() > 2)
523 .map(|w| stemmer.stem(&w.to_lowercase()).to_string())
524 .collect();
525
526 Some(TemporalFact {
527 id: format!("tf-{}-{}", memory_id.0, uuid::Uuid::new_v4()),
528 entity: entity.to_string(),
529 event: event.clone(),
530 event_stems,
531 event_type: EventType::Historical,
532 relative_time: Some(year.to_string()),
533 resolved_time: ResolvedTime::Year(year),
534 source_memory_id: memory_id.clone(),
535 conversation_date,
536 confidence: 0.85,
537 source_text: sentence.to_string(),
538 })
539}
540
541fn extract_event_and_time(sentence: &str, patterns: &[&str]) -> (String, String) {
543 let sentence_lower = sentence.to_lowercase();
544
545 let _matched_pattern = patterns
547 .iter()
548 .find(|p| sentence_lower.contains(*p))
549 .map(|s| *s)
550 .unwrap_or("");
551
552 let relative_time = extract_time_expression(&sentence_lower);
554
555 let event = extract_event_from_sentence(sentence);
557
558 (event, relative_time)
559}
560
561fn extract_time_expression(sentence: &str) -> String {
563 let time_patterns = [
564 "next month",
565 "next week",
566 "next year",
567 "this weekend",
568 "this month",
569 "last saturday",
570 "last sunday",
571 "last monday",
572 "last tuesday",
573 "last wednesday",
574 "last thursday",
575 "last friday",
576 "last week",
577 "last weekend",
578 "last month",
579 "last year",
580 "yesterday",
581 "today",
582 "tomorrow",
583 "two weeks ago",
584 "a few days ago",
585 "a week ago",
586 ];
587
588 for pattern in time_patterns {
589 if sentence.contains(pattern) {
590 return pattern.to_string();
591 }
592 }
593
594 if let Some(idx) = sentence.find(" ago") {
596 let before = &sentence[..idx];
597 let words: Vec<&str> = before.split_whitespace().collect();
598 if words.len() >= 2 {
599 let last_two = words[words.len() - 2..].join(" ");
600 return format!("{} ago", last_two);
601 }
602 }
603
604 String::new()
605}
606
607fn extract_event_from_sentence(sentence: &str) -> String {
609 let stopwords: HashSet<&str> = [
610 "i",
611 "we",
612 "you",
613 "they",
614 "he",
615 "she",
616 "it",
617 "the",
618 "a",
619 "an",
620 "is",
621 "are",
622 "was",
623 "were",
624 "be",
625 "been",
626 "being",
627 "have",
628 "has",
629 "had",
630 "do",
631 "does",
632 "did",
633 "will",
634 "would",
635 "could",
636 "should",
637 "may",
638 "might",
639 "must",
640 "shall",
641 "can",
642 "to",
643 "of",
644 "in",
645 "for",
646 "on",
647 "with",
648 "at",
649 "by",
650 "from",
651 "as",
652 "into",
653 "through",
654 "during",
655 "before",
656 "after",
657 "above",
658 "below",
659 "between",
660 "under",
661 "again",
662 "further",
663 "then",
664 "once",
665 "here",
666 "there",
667 "when",
668 "where",
669 "why",
670 "how",
671 "all",
672 "each",
673 "few",
674 "more",
675 "most",
676 "other",
677 "some",
678 "such",
679 "no",
680 "nor",
681 "not",
682 "only",
683 "own",
684 "same",
685 "so",
686 "than",
687 "too",
688 "very",
689 "just",
690 "about",
691 "really",
692 "also",
693 "that",
694 "this",
695 "these",
696 "those",
697 "am",
698 "going",
699 "planning",
700 "thinking",
701 "want",
702 "hope",
703 "looking",
704 "forward",
705 "excited",
706 "last",
707 "next",
708 "week",
709 "month",
710 "year",
711 "saturday",
712 "sunday",
713 "monday",
714 "tuesday",
715 "wednesday",
716 "thursday",
717 "friday",
718 "weekend",
719 "ago",
720 "recently",
721 ]
722 .into_iter()
723 .collect();
724
725 let words: Vec<&str> = sentence
727 .split(|c: char| !c.is_alphanumeric())
728 .filter(|w| {
729 let w_lower = w.to_lowercase();
730 w.len() > 2 && !stopwords.contains(w_lower.as_str())
731 })
732 .take(5)
733 .collect();
734
735 words.join(" ")
736}
737
738pub fn resolve_relative_time(relative: &str, conversation_date: DateTime<Utc>) -> ResolvedTime {
744 let relative_lower = relative.to_lowercase();
745 let conv_date = conversation_date.date_naive();
746
747 if relative_lower.contains("next month") {
749 let next_month = if conv_date.month() == 12 {
750 (1, conv_date.year() + 1)
751 } else {
752 (conv_date.month() + 1, conv_date.year())
753 };
754 return ResolvedTime::MonthYear {
755 month: next_month.0,
756 year: next_month.1,
757 };
758 }
759
760 if relative_lower.contains("last month") {
762 let last_month = if conv_date.month() == 1 {
763 (12, conv_date.year() - 1)
764 } else {
765 (conv_date.month() - 1, conv_date.year())
766 };
767 return ResolvedTime::MonthYear {
768 month: last_month.0,
769 year: last_month.1,
770 };
771 }
772
773 if relative_lower.contains("this month") {
775 return ResolvedTime::MonthYear {
776 month: conv_date.month(),
777 year: conv_date.year(),
778 };
779 }
780
781 if relative_lower.contains("next week") {
783 let next_week = conv_date + Duration::days(7);
784 return ResolvedTime::ExactDate(next_week);
785 }
786
787 if relative_lower.contains("last week") {
789 let last_week = conv_date - Duration::days(7);
790 return ResolvedTime::ExactDate(last_week);
791 }
792
793 if relative_lower.contains("yesterday") {
795 return ResolvedTime::ExactDate(conv_date - Duration::days(1));
796 }
797
798 if relative_lower.contains("today") {
800 return ResolvedTime::ExactDate(conv_date);
801 }
802
803 if relative_lower.contains("tomorrow") {
805 return ResolvedTime::ExactDate(conv_date + Duration::days(1));
806 }
807
808 let weekdays = [
810 ("sunday", Weekday::Sun),
811 ("monday", Weekday::Mon),
812 ("tuesday", Weekday::Tue),
813 ("wednesday", Weekday::Wed),
814 ("thursday", Weekday::Thu),
815 ("friday", Weekday::Fri),
816 ("saturday", Weekday::Sat),
817 ];
818
819 for (name, weekday) in weekdays {
820 if relative_lower.contains(&format!("last {}", name)) {
821 let resolved = last_weekday(conv_date, weekday);
822 return ResolvedTime::ExactDate(resolved);
823 }
824 if relative_lower.contains(&format!("this {}", name))
825 || relative_lower.contains(&format!("next {}", name))
826 {
827 let resolved = next_weekday(conv_date, weekday);
828 return ResolvedTime::ExactDate(resolved);
829 }
830 }
831
832 if relative_lower.contains("ago") {
834 if let Some(days) = parse_ago_expression(&relative_lower) {
835 return ResolvedTime::ExactDate(conv_date - Duration::days(days));
836 }
837 }
838
839 if relative_lower.contains("this weekend") {
841 let days_until_saturday = (Weekday::Sat.num_days_from_monday() as i64
842 - conv_date.weekday().num_days_from_monday() as i64
843 + 7)
844 % 7;
845 let saturday = conv_date + Duration::days(days_until_saturday);
846 return ResolvedTime::ExactDate(saturday);
847 }
848
849 if let Ok(year) = relative_lower.trim().parse::<i32>() {
851 if (1900..2100).contains(&year) {
852 return ResolvedTime::Year(year);
853 }
854 }
855
856 ResolvedTime::RelativeDescription(relative.to_string())
857}
858
859fn last_weekday(from: NaiveDate, target: Weekday) -> NaiveDate {
861 let current_weekday = from.weekday();
862 let days_back =
863 (current_weekday.num_days_from_monday() as i64 - target.num_days_from_monday() as i64 + 7)
864 % 7;
865 let days_back = if days_back == 0 { 7 } else { days_back };
866 from - Duration::days(days_back)
867}
868
869fn next_weekday(from: NaiveDate, target: Weekday) -> NaiveDate {
871 let current_weekday = from.weekday();
872 let days_forward =
873 (target.num_days_from_monday() as i64 - current_weekday.num_days_from_monday() as i64 + 7)
874 % 7;
875 let days_forward = if days_forward == 0 { 7 } else { days_forward };
876 from + Duration::days(days_forward)
877}
878
879fn parse_ago_expression(expr: &str) -> Option<i64> {
881 let words: Vec<&str> = expr.split_whitespace().collect();
882
883 let ago_pos = words.iter().position(|w| *w == "ago")?;
885 if ago_pos < 2 {
886 return None;
887 }
888
889 let unit = words[ago_pos - 1];
890 let num_str = words[ago_pos - 2];
891
892 let num: i64 = match num_str {
893 "a" | "one" => 1,
894 "two" => 2,
895 "three" => 3,
896 "four" => 4,
897 "five" => 5,
898 "few" => 3,
899 "couple" => 2,
900 _ => num_str.parse().ok()?,
901 };
902
903 let multiplier = match unit {
904 "day" | "days" => 1,
905 "week" | "weeks" => 7,
906 "month" | "months" => 30,
907 "year" | "years" => 365,
908 _ => return None,
909 };
910
911 Some(num * multiplier)
912}
913
914fn month_name(month: u32) -> &'static str {
916 match month {
917 1 => "January",
918 2 => "February",
919 3 => "March",
920 4 => "April",
921 5 => "May",
922 6 => "June",
923 7 => "July",
924 8 => "August",
925 9 => "September",
926 10 => "October",
927 11 => "November",
928 12 => "December",
929 _ => "Unknown",
930 }
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936
937 #[test]
938 fn test_resolve_next_month() {
939 let conv_date = DateTime::parse_from_rfc3339("2023-05-25T13:14:00Z")
940 .unwrap()
941 .with_timezone(&Utc);
942
943 let resolved = resolve_relative_time("next month", conv_date);
944
945 match resolved {
946 ResolvedTime::MonthYear { month, year } => {
947 assert_eq!(month, 6);
948 assert_eq!(year, 2023);
949 }
950 _ => panic!("Expected MonthYear"),
951 }
952 }
953
954 #[test]
955 fn test_resolve_last_saturday() {
956 let conv_date = DateTime::parse_from_rfc3339("2023-05-25T13:14:00Z")
958 .unwrap()
959 .with_timezone(&Utc);
960
961 let resolved = resolve_relative_time("last saturday", conv_date);
962
963 match resolved {
964 ResolvedTime::ExactDate(d) => {
965 assert_eq!(d.to_string(), "2023-05-20");
967 }
968 _ => panic!("Expected ExactDate"),
969 }
970 }
971
972 #[test]
973 fn test_extract_event() {
974 let sentence = "We're thinking about going camping next month";
975 let event = extract_event_from_sentence(sentence);
976 assert!(event.to_lowercase().contains("camping"));
977 }
978}