Skip to main content

shodh_memory/memory/
temporal_facts.rs

1//! Temporal Fact Extraction and Storage
2//!
3//! Extracts and stores temporal facts from conversations for multi-hop reasoning.
4//! Key insight: Multi-hop temporal queries like "When is X planning Y?" require:
5//! 1. Finding the FIRST/PLANNING mention, not any mention
6//! 2. Resolving relative dates ("next month", "last Saturday") to absolute dates
7//! 3. Indexing by entity + event for fast lookup
8//!
9//! Storage schema:
10//! - `temporal_facts:{user_id}:{fact_id}` - Primary storage
11//! - `temporal_by_entity:{user_id}:{entity}:{fact_id}` - Entity index
12//! - `temporal_by_event:{user_id}:{event_stem}:{fact_id}` - Event index
13
14use 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/// Type of temporal event
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum EventType {
27    /// Event was planned/scheduled ("We're planning to go camping next month")
28    Planned,
29    /// Event occurred/happened ("I ran a charity race last Saturday")
30    Occurred,
31    /// Event was mentioned in past tense referring to history ("I painted that in 2022")
32    Historical,
33    /// Recurring event ("We always go camping in summer")
34    Recurring,
35}
36
37/// A temporal fact extracted from conversation
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct TemporalFact {
40    /// Unique identifier
41    pub id: String,
42    /// Entity involved (e.g., "Melanie", "Caroline")
43    pub entity: String,
44    /// Event/action (e.g., "camping", "charity race", "painted sunrise")
45    pub event: String,
46    /// Stemmed event keywords for matching
47    pub event_stems: Vec<String>,
48    /// Type of event (planned, occurred, historical)
49    pub event_type: EventType,
50    /// Original relative time expression ("next month", "last Saturday")
51    pub relative_time: Option<String>,
52    /// Resolved absolute time
53    pub resolved_time: ResolvedTime,
54    /// Source memory ID
55    pub source_memory_id: MemoryId,
56    /// Conversation timestamp (used to resolve relative dates)
57    pub conversation_date: DateTime<Utc>,
58    /// Confidence in extraction (0.0-1.0)
59    pub confidence: f32,
60    /// Original sentence fragment
61    pub source_text: String,
62}
63
64/// Resolved time representation
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub enum ResolvedTime {
67    /// Exact date (e.g., "May 7, 2023")
68    ExactDate(NaiveDate),
69    /// Month and year (e.g., "June 2023")
70    MonthYear { month: u32, year: i32 },
71    /// Just year (e.g., "2022")
72    Year(i32),
73    /// Relative to conversation ("the week before", "next month")
74    RelativeDescription(String),
75    /// Unknown/couldn't resolve
76    Unknown,
77}
78
79impl ResolvedTime {
80    /// Convert to a sortable string for comparison
81    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    /// Check if this time matches a query time expression
92    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        // Direct match
97        if self_str.contains(&query_lower) || query_lower.contains(&self_str) {
98            return true;
99        }
100
101        // Month name matching
102        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
113/// Storage for temporal facts
114pub struct TemporalFactStore {
115    db: Arc<DB>,
116}
117
118impl TemporalFactStore {
119    pub fn new(db: Arc<DB>) -> Self {
120        Self { db }
121    }
122
123    /// Store a temporal fact
124    pub fn store(&self, user_id: &str, fact: &TemporalFact) -> Result<()> {
125        // Primary storage
126        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        // Entity index
131        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        // Event index (by each stem)
140        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    /// Store multiple facts
149    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    /// Get a fact by ID
160    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    /// Find facts by entity
173    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    /// Find facts by event keyword
184    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    /// Find facts matching entity AND event
197    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        // Get facts by entity
205        let entity_facts = self.find_by_entity(user_id, entity, 100)?;
206
207        // Filter by event keywords
208        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                // Check if any event stem matches
218                let has_event_match = f.event_stems.iter().any(|s| event_stems.contains(s));
219                // Check event type if specified
220                let type_matches = event_type.map_or(true, |t| f.event_type == t);
221                has_event_match && type_matches
222            })
223            .collect();
224
225        // Sort by conversation date (earliest first for "planning" queries)
226        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    /// List all temporal facts for a user
268    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
300// ============================================================================
301// TEMPORAL FACT EXTRACTION
302// ============================================================================
303
304/// Extract temporal facts from memory content
305pub 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    // Split into sentences
315    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        // Find which entity is mentioned in this sentence
324        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        // Try to extract temporal facts using patterns
334        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
352/// Extract "planning" facts (future events)
353fn 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    // Planning patterns
363    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    // Extract event and time
384    let (event, relative_time) = extract_event_and_time(sentence, &planning_patterns);
385    if event.is_empty() {
386        return None;
387    }
388
389    // Resolve relative time
390    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
417/// Extract "occurred" facts (past events)
418fn 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    // Past event patterns
428    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    // Check for past tense verbs combined with time patterns
450    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    // Extract event and time
461    let (event, relative_time) = extract_event_and_time(sentence, &occurred_patterns);
462    if event.is_empty() {
463        return None;
464    }
465
466    // Resolve relative time
467    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
494/// Extract "historical" facts (events in distant past)
495fn 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    // Look for year references (historical)
505    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    // Only treat as historical if year is before conversation year
510    if year >= conversation_date.year() {
511        return None;
512    }
513
514    // Extract what happened
515    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
541/// Extract event description and time expression from sentence
542fn extract_event_and_time(sentence: &str, patterns: &[&str]) -> (String, String) {
543    let sentence_lower = sentence.to_lowercase();
544
545    // Find which pattern matched (used to verify a pattern exists)
546    let _matched_pattern = patterns
547        .iter()
548        .find(|p| sentence_lower.contains(*p))
549        .map(|s| *s)
550        .unwrap_or("");
551
552    // Extract time expression
553    let relative_time = extract_time_expression(&sentence_lower);
554
555    // Extract event (words around the pattern)
556    let event = extract_event_from_sentence(sentence);
557
558    (event, relative_time)
559}
560
561/// Extract time expression from sentence
562fn 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    // Check for "X ago" pattern
595    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
607/// Extract main event/action from sentence
608fn 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    // Extract content words
726    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
738// ============================================================================
739// RELATIVE DATE RESOLUTION
740// ============================================================================
741
742/// Resolve relative time expression to absolute time
743pub 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    // "next month"
748    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    // "last month"
761    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    // "this month"
774    if relative_lower.contains("this month") {
775        return ResolvedTime::MonthYear {
776            month: conv_date.month(),
777            year: conv_date.year(),
778        };
779    }
780
781    // "next week"
782    if relative_lower.contains("next week") {
783        let next_week = conv_date + Duration::days(7);
784        return ResolvedTime::ExactDate(next_week);
785    }
786
787    // "last week"
788    if relative_lower.contains("last week") {
789        let last_week = conv_date - Duration::days(7);
790        return ResolvedTime::ExactDate(last_week);
791    }
792
793    // "yesterday"
794    if relative_lower.contains("yesterday") {
795        return ResolvedTime::ExactDate(conv_date - Duration::days(1));
796    }
797
798    // "today"
799    if relative_lower.contains("today") {
800        return ResolvedTime::ExactDate(conv_date);
801    }
802
803    // "tomorrow"
804    if relative_lower.contains("tomorrow") {
805        return ResolvedTime::ExactDate(conv_date + Duration::days(1));
806    }
807
808    // "last saturday", "last sunday", etc.
809    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    // "X weeks/days ago"
833    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    // "this weekend"
840    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    // Year only (e.g., "2022")
850    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
859/// Find the last occurrence of a weekday before the given date
860fn 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
869/// Find the next occurrence of a weekday after the given date
870fn 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
879/// Parse "X days/weeks ago" expressions
880fn parse_ago_expression(expr: &str) -> Option<i64> {
881    let words: Vec<&str> = expr.split_whitespace().collect();
882
883    // Find "ago" position
884    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
914/// Get month name from number
915fn 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        // May 25, 2023 is a Thursday
957        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                // Last Saturday before May 25 (Thursday) is May 20
966                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}