1use chrono::{DateTime, Utc};
2use serde_json::Value;
3use std::collections::HashMap;
4
5use crate::serde::CONTENT_TYPE_TOMBSTONE;
6
7#[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#[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#[derive(Debug, Clone)]
43pub struct SearchResult {
44 pub record: ContextRecord,
45 pub distance: f32,
46}
47
48#[derive(Debug, Clone, PartialEq)]
50pub enum MetadataFilter {
51 Equals(Value),
52 Contains(Value),
53}
54
55#[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}