Skip to main content

lance_context_core/
record.rs

1use chrono::{DateTime, Utc};
2use serde_json::Value;
3use std::collections::HashMap;
4
5use crate::serde::CONTENT_TYPE_TOMBSTONE;
6
7/// Structured metadata captured alongside each context entry.
8#[derive(Debug, Clone, Default)]
9pub struct StateMetadata {
10    pub step: Option<i32>,
11    pub active_plan_id: Option<String>,
12    pub tokens_used: Option<i32>,
13    pub custom: Option<String>,
14}
15
16/// User-facing representation of a context entry written to storage.
17#[derive(Debug, Clone)]
18pub struct ContextRecord {
19    pub id: String,
20    pub external_id: Option<String>,
21    pub run_id: String,
22    pub bot_id: Option<String>,
23    pub session_id: Option<String>,
24    pub created_at: DateTime<Utc>,
25    pub role: String,
26    pub state_metadata: Option<StateMetadata>,
27    pub metadata: Option<Value>,
28    pub content_type: String,
29    pub text_payload: Option<String>,
30    pub binary_payload: Option<Vec<u8>>,
31    pub embedding: Option<Vec<f32>>,
32}
33
34impl ContextRecord {
35    #[must_use]
36    pub fn is_tombstone(&self) -> bool {
37        self.content_type == CONTENT_TYPE_TOMBSTONE
38    }
39}
40
41/// Result returned from a vector similarity search.
42#[derive(Debug, Clone)]
43pub struct SearchResult {
44    pub record: ContextRecord,
45    pub distance: f32,
46}
47
48/// Metadata matching operation for filtered retrieval.
49#[derive(Debug, Clone, PartialEq)]
50pub enum MetadataFilter {
51    Equals(Value),
52    Contains(Value),
53}
54
55/// Filters applied to records before list pagination or search ranking.
56#[derive(Debug, Clone, Default, PartialEq)]
57pub struct RecordFilters {
58    pub bot_id: Option<String>,
59    pub session_id: Option<String>,
60    pub role: Option<String>,
61    pub content_type: Option<String>,
62    pub created_at_start: Option<DateTime<Utc>>,
63    pub created_at_end: Option<DateTime<Utc>>,
64    pub metadata: HashMap<String, MetadataFilter>,
65}
66
67impl RecordFilters {
68    #[must_use]
69    pub fn is_empty(&self) -> bool {
70        self.bot_id.is_none()
71            && self.session_id.is_none()
72            && self.role.is_none()
73            && self.content_type.is_none()
74            && self.created_at_start.is_none()
75            && self.created_at_end.is_none()
76            && self.metadata.is_empty()
77    }
78
79    #[must_use]
80    pub fn matches(&self, record: &ContextRecord) -> bool {
81        if self
82            .bot_id
83            .as_deref()
84            .is_some_and(|value| record.bot_id.as_deref() != Some(value))
85        {
86            return false;
87        }
88        if self
89            .session_id
90            .as_deref()
91            .is_some_and(|value| record.session_id.as_deref() != Some(value))
92        {
93            return false;
94        }
95        if self
96            .role
97            .as_deref()
98            .is_some_and(|value| record.role != value)
99        {
100            return false;
101        }
102        if self
103            .content_type
104            .as_deref()
105            .is_some_and(|value| record.content_type != value)
106        {
107            return false;
108        }
109        if self
110            .created_at_start
111            .is_some_and(|start| record.created_at < start)
112        {
113            return false;
114        }
115        if self
116            .created_at_end
117            .is_some_and(|end| record.created_at > end)
118        {
119            return false;
120        }
121
122        self.metadata.iter().all(|(key, filter)| {
123            let Some(Value::Object(metadata)) = &record.metadata else {
124                return false;
125            };
126            let Some(value) = metadata.get(key) else {
127                return false;
128            };
129            match filter {
130                MetadataFilter::Equals(expected) => value == expected,
131                MetadataFilter::Contains(expected) => metadata_contains(value, expected),
132            }
133        })
134    }
135}
136
137fn metadata_contains(value: &Value, expected: &Value) -> bool {
138    match (value, expected) {
139        (Value::Array(items), expected) => items.iter().any(|item| item == expected),
140        (Value::String(value), Value::String(expected)) => value.contains(expected),
141        _ => false,
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use chrono::TimeZone;
149    use serde_json::json;
150
151    fn record() -> ContextRecord {
152        ContextRecord {
153            id: "rec-1".to_string(),
154            external_id: None,
155            run_id: "run-1".to_string(),
156            bot_id: Some("support-bot".to_string()),
157            session_id: Some("incident-1".to_string()),
158            created_at: Utc.with_ymd_and_hms(2026, 6, 9, 3, 0, 0).unwrap(),
159            role: "assistant".to_string(),
160            state_metadata: None,
161            metadata: Some(json!({
162                "scope": "team",
163                "tags": ["runbook", "ownership"],
164                "confidence": 0.92
165            })),
166            content_type: "text/plain".to_string(),
167            text_payload: Some("hello".to_string()),
168            binary_payload: None,
169            embedding: None,
170        }
171    }
172
173    #[test]
174    fn filters_match_builtin_fields_timestamps_and_metadata() {
175        let mut filters = RecordFilters {
176            bot_id: Some("support-bot".to_string()),
177            session_id: Some("incident-1".to_string()),
178            role: Some("assistant".to_string()),
179            content_type: Some("text/plain".to_string()),
180            created_at_start: Some(Utc.with_ymd_and_hms(2026, 6, 9, 2, 0, 0).unwrap()),
181            created_at_end: Some(Utc.with_ymd_and_hms(2026, 6, 9, 4, 0, 0).unwrap()),
182            metadata: HashMap::new(),
183        };
184        filters
185            .metadata
186            .insert("scope".to_string(), MetadataFilter::Equals(json!("team")));
187        filters.metadata.insert(
188            "tags".to_string(),
189            MetadataFilter::Contains(json!("runbook")),
190        );
191
192        assert!(filters.matches(&record()));
193
194        filters.session_id = Some("other".to_string());
195        assert!(!filters.matches(&record()));
196    }
197}