use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use cognis_core::documents::Document;
use cognis_core::error::{CognisError, Result};
use cognis_core::language_models::chat_model::BaseChatModel;
use cognis_core::messages::{HumanMessage, Message};
use cognis_core::retrievers::BaseRetriever;
use cognis_core::vectorstores::base::VectorStore;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum FilterValue {
String(String),
Integer(i64),
Float(f64),
Bool(bool),
}
impl FilterValue {
pub fn to_json_value(&self) -> Value {
match self {
FilterValue::String(s) => Value::String(s.clone()),
FilterValue::Integer(i) => Value::Number((*i).into()),
FilterValue::Float(f) => serde_json::Number::from_f64(*f)
.map(Value::Number)
.unwrap_or(Value::Null),
FilterValue::Bool(b) => Value::Bool(*b),
}
}
fn as_f64(&self) -> Option<f64> {
match self {
FilterValue::Integer(i) => Some(*i as f64),
FilterValue::Float(f) => Some(*f),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "operator", content = "args")]
pub enum AttributeFilter {
Eq { field: String, value: FilterValue },
Ne { field: String, value: FilterValue },
Gt { field: String, value: FilterValue },
Gte { field: String, value: FilterValue },
Lt { field: String, value: FilterValue },
Lte { field: String, value: FilterValue },
In {
field: String,
values: Vec<FilterValue>,
},
Nin {
field: String,
values: Vec<FilterValue>,
},
And(Vec<AttributeFilter>),
Or(Vec<AttributeFilter>),
Not(Box<AttributeFilter>),
}
impl AttributeFilter {
pub fn matches(&self, metadata: &HashMap<String, Value>) -> bool {
match self {
AttributeFilter::Eq { field, value } => metadata
.get(field)
.is_some_and(|v| *v == value.to_json_value()),
AttributeFilter::Ne { field, value } => metadata
.get(field)
.is_none_or(|v| *v != value.to_json_value()),
AttributeFilter::Gt { field, value } => {
compare_metadata_numeric(metadata, field, value, |a, b| a > b)
}
AttributeFilter::Gte { field, value } => {
compare_metadata_numeric(metadata, field, value, |a, b| a >= b)
}
AttributeFilter::Lt { field, value } => {
compare_metadata_numeric(metadata, field, value, |a, b| a < b)
}
AttributeFilter::Lte { field, value } => {
compare_metadata_numeric(metadata, field, value, |a, b| a <= b)
}
AttributeFilter::In { field, values } => metadata
.get(field)
.is_some_and(|v| values.iter().any(|fv| *v == fv.to_json_value())),
AttributeFilter::Nin { field, values } => metadata
.get(field)
.is_none_or(|v| !values.iter().any(|fv| *v == fv.to_json_value())),
AttributeFilter::And(filters) => filters.iter().all(|f| f.matches(metadata)),
AttributeFilter::Or(filters) => filters.iter().any(|f| f.matches(metadata)),
AttributeFilter::Not(filter) => !filter.matches(metadata),
}
}
}
fn compare_metadata_numeric(
metadata: &HashMap<String, Value>,
field: &str,
filter_value: &FilterValue,
cmp: fn(f64, f64) -> bool,
) -> bool {
let Some(meta_val) = metadata.get(field) else {
return false;
};
let meta_f64 = match meta_val {
Value::Number(n) => n.as_f64(),
_ => None,
};
let filter_f64 = filter_value.as_f64();
match (meta_f64, filter_f64) {
(Some(a), Some(b)) => cmp(a, b),
_ => false,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuredQuery {
pub query: String,
pub filter: Option<AttributeFilter>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttributeInfo {
pub name: String,
pub data_type: String,
pub description: String,
}
impl AttributeInfo {
pub fn new(
name: impl Into<String>,
data_type: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
name: name.into(),
data_type: data_type.into(),
description: description.into(),
}
}
}
pub struct QueryConstructor {
llm: Arc<dyn BaseChatModel>,
attribute_info: Vec<AttributeInfo>,
document_contents: String,
}
impl QueryConstructor {
pub fn new(
llm: Arc<dyn BaseChatModel>,
attribute_info: Vec<AttributeInfo>,
document_contents: impl Into<String>,
) -> Self {
Self {
llm,
attribute_info,
document_contents: document_contents.into(),
}
}
fn build_prompt(&self, query: &str) -> String {
let mut attrs_desc = String::new();
for attr in &self.attribute_info {
attrs_desc.push_str(&format!(
"- \"{}\": type={}, description=\"{}\"\n",
attr.name, attr.data_type, attr.description
));
}
format!(
r#"You are a query parser. Given a natural language query about a collection of documents, extract:
1. A semantic search query (the part about the content/meaning)
2. An optional metadata filter (conditions on document attributes)
The document collection contains: {document_contents}
Available metadata attributes:
{attrs_desc}
Supported filter operators: eq, ne, gt, gte, lt, lte, in, nin, and, or, not
Respond with ONLY a JSON object (no markdown, no explanation) in this exact format:
{{
"query": "<semantic search query>",
"filter": <filter object or null>
}}
Filter format examples:
- {{"operator": "eq", "field": "genre", "value": "sci-fi"}}
- {{"operator": "gt", "field": "year", "value": 2020}}
- {{"operator": "in", "field": "genre", "values": ["sci-fi", "action"]}}
- {{"operator": "and", "filters": [{{"operator": "eq", "field": "genre", "value": "sci-fi"}}, {{"operator": "gt", "field": "year", "value": 2020}}]}}
- {{"operator": "not", "filter": {{"operator": "eq", "field": "genre", "value": "horror"}}}}
Query: {query}"#,
document_contents = self.document_contents,
attrs_desc = attrs_desc,
query = query,
)
}
pub async fn construct(&self, query: &str) -> Result<StructuredQuery> {
let prompt = self.build_prompt(query);
let messages = vec![Message::Human(HumanMessage::new(&prompt))];
let ai_msg = self.llm.invoke_messages(&messages, None).await?;
let response_text = ai_msg.base.content.text();
parse_structured_query(&response_text)
}
}
fn parse_structured_query(response: &str) -> Result<StructuredQuery> {
let trimmed = response.trim();
let json_str = if trimmed.starts_with("```") {
let inner = trimmed
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
inner
} else {
trimmed
};
let raw: Value =
serde_json::from_str(json_str).map_err(|e| CognisError::OutputParserError {
message: format!("Failed to parse LLM response as JSON: {e}"),
observation: Some(response.to_string()),
llm_output: Some(response.to_string()),
})?;
let query = raw
.get("query")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let filter = if let Some(filter_val) = raw.get("filter") {
if filter_val.is_null() {
None
} else {
Some(parse_filter(filter_val)?)
}
} else {
None
};
Ok(StructuredQuery { query, filter })
}
fn parse_filter(val: &Value) -> Result<AttributeFilter> {
let obj = val
.as_object()
.ok_or_else(|| CognisError::OutputParserError {
message: "Filter must be a JSON object".into(),
observation: Some(val.to_string()),
llm_output: None,
})?;
let operator = obj
.get("operator")
.and_then(|v| v.as_str())
.ok_or_else(|| CognisError::OutputParserError {
message: "Filter must have an 'operator' field".into(),
observation: Some(val.to_string()),
llm_output: None,
})?;
match operator {
"eq" | "ne" | "gt" | "gte" | "lt" | "lte" => {
let field = obj
.get("field")
.and_then(|v| v.as_str())
.ok_or_else(|| CognisError::OutputParserError {
message: format!("Filter '{operator}' must have a 'field' string"),
observation: Some(val.to_string()),
llm_output: None,
})?
.to_string();
let value = obj
.get("value")
.ok_or_else(|| CognisError::OutputParserError {
message: format!("Filter '{operator}' must have a 'value' field"),
observation: Some(val.to_string()),
llm_output: None,
})?;
let fv = json_to_filter_value(value)?;
Ok(match operator {
"eq" => AttributeFilter::Eq { field, value: fv },
"ne" => AttributeFilter::Ne { field, value: fv },
"gt" => AttributeFilter::Gt { field, value: fv },
"gte" => AttributeFilter::Gte { field, value: fv },
"lt" => AttributeFilter::Lt { field, value: fv },
"lte" => AttributeFilter::Lte { field, value: fv },
_ => unreachable!(),
})
}
"in" | "nin" => {
let field = obj
.get("field")
.and_then(|v| v.as_str())
.ok_or_else(|| CognisError::OutputParserError {
message: format!("Filter '{operator}' must have a 'field' string"),
observation: Some(val.to_string()),
llm_output: None,
})?
.to_string();
let values_arr = obj
.get("values")
.and_then(|v| v.as_array())
.ok_or_else(|| CognisError::OutputParserError {
message: format!("Filter '{operator}' must have a 'values' array"),
observation: Some(val.to_string()),
llm_output: None,
})?;
let values: Result<Vec<FilterValue>> =
values_arr.iter().map(json_to_filter_value).collect();
let values = values?;
Ok(match operator {
"in" => AttributeFilter::In { field, values },
"nin" => AttributeFilter::Nin { field, values },
_ => unreachable!(),
})
}
"and" | "or" => {
let filters_arr = obj
.get("filters")
.and_then(|v| v.as_array())
.ok_or_else(|| CognisError::OutputParserError {
message: format!("Filter '{operator}' must have a 'filters' array"),
observation: Some(val.to_string()),
llm_output: None,
})?;
let filters: Result<Vec<AttributeFilter>> =
filters_arr.iter().map(parse_filter).collect();
let filters = filters?;
Ok(match operator {
"and" => AttributeFilter::And(filters),
"or" => AttributeFilter::Or(filters),
_ => unreachable!(),
})
}
"not" => {
let inner = obj
.get("filter")
.ok_or_else(|| CognisError::OutputParserError {
message: "Filter 'not' must have a 'filter' field".into(),
observation: Some(val.to_string()),
llm_output: None,
})?;
let inner_filter = parse_filter(inner)?;
Ok(AttributeFilter::Not(Box::new(inner_filter)))
}
other => Err(CognisError::OutputParserError {
message: format!("Unknown filter operator: '{other}'"),
observation: Some(val.to_string()),
llm_output: None,
}),
}
}
fn json_to_filter_value(val: &Value) -> Result<FilterValue> {
match val {
Value::String(s) => Ok(FilterValue::String(s.clone())),
Value::Bool(b) => Ok(FilterValue::Bool(*b)),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(FilterValue::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(FilterValue::Float(f))
} else {
Err(CognisError::OutputParserError {
message: format!("Cannot convert number to FilterValue: {n}"),
observation: None,
llm_output: None,
})
}
}
_ => Err(CognisError::OutputParserError {
message: format!("Unsupported filter value type: {val}"),
observation: None,
llm_output: None,
}),
}
}
pub struct SelfQueryRetriever {
vectorstore: Arc<dyn VectorStore>,
query_constructor: QueryConstructor,
k: usize,
enable_filter: bool,
}
pub struct SelfQueryRetrieverBuilder {
vectorstore: Option<Arc<dyn VectorStore>>,
llm: Option<Arc<dyn BaseChatModel>>,
document_contents: Option<String>,
attribute_info: Vec<AttributeInfo>,
k: usize,
enable_filter: bool,
}
impl SelfQueryRetrieverBuilder {
pub fn new() -> Self {
Self {
vectorstore: None,
llm: None,
document_contents: None,
attribute_info: Vec::new(),
k: 4,
enable_filter: true,
}
}
pub fn vectorstore(mut self, vectorstore: Arc<dyn VectorStore>) -> Self {
self.vectorstore = Some(vectorstore);
self
}
pub fn llm(mut self, llm: Arc<dyn BaseChatModel>) -> Self {
self.llm = Some(llm);
self
}
pub fn document_contents(mut self, description: impl Into<String>) -> Self {
self.document_contents = Some(description.into());
self
}
pub fn attribute_info(mut self, info: Vec<AttributeInfo>) -> Self {
self.attribute_info = info;
self
}
pub fn k(mut self, k: usize) -> Self {
self.k = k;
self
}
pub fn enable_filter(mut self, enable: bool) -> Self {
self.enable_filter = enable;
self
}
pub fn build(self) -> SelfQueryRetriever {
let vectorstore = self
.vectorstore
.expect("vectorstore is required for SelfQueryRetriever");
let llm = self.llm.expect("llm is required for SelfQueryRetriever");
let document_contents = self
.document_contents
.expect("document_contents is required for SelfQueryRetriever");
let query_constructor = QueryConstructor::new(llm, self.attribute_info, document_contents);
SelfQueryRetriever {
vectorstore,
query_constructor,
k: self.k,
enable_filter: self.enable_filter,
}
}
}
impl Default for SelfQueryRetrieverBuilder {
fn default() -> Self {
Self::new()
}
}
impl SelfQueryRetriever {
pub fn builder() -> SelfQueryRetrieverBuilder {
SelfQueryRetrieverBuilder::new()
}
}
#[async_trait]
impl BaseRetriever for SelfQueryRetriever {
async fn get_relevant_documents(&self, query: &str) -> Result<Vec<Document>> {
let structured = self.query_constructor.construct(query).await?;
let search_query = if structured.query.is_empty() {
query.to_string()
} else {
structured.query
};
let docs = self
.vectorstore
.similarity_search(&search_query, self.k)
.await?;
if self.enable_filter {
if let Some(filter) = &structured.filter {
return Ok(docs
.into_iter()
.filter(|doc| filter.matches(&doc.metadata))
.collect());
}
}
Ok(docs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vectorstores::in_memory::InMemoryVectorStore;
use cognis_core::embeddings_fake::DeterministicFakeEmbedding;
use cognis_core::language_models::fake::FakeListChatModel;
use serde_json::json;
fn make_embeddings() -> Arc<dyn cognis_core::embeddings::Embeddings> {
Arc::new(DeterministicFakeEmbedding::new(16))
}
fn fake_llm(responses: Vec<&str>) -> Arc<dyn BaseChatModel> {
Arc::new(FakeListChatModel::new(
responses.into_iter().map(String::from).collect(),
))
}
fn movie_docs() -> Vec<Document> {
vec![
Document::new("A mind-bending sci-fi thriller").with_metadata(HashMap::from([
("genre".into(), json!("sci-fi")),
("year".into(), json!(2023)),
("rating".into(), json!(8.5)),
])),
Document::new("A romantic comedy about love").with_metadata(HashMap::from([
("genre".into(), json!("comedy")),
("year".into(), json!(2019)),
("rating".into(), json!(6.2)),
])),
Document::new("An action-packed adventure film").with_metadata(HashMap::from([
("genre".into(), json!("action")),
("year".into(), json!(2021)),
("rating".into(), json!(7.8)),
])),
Document::new("A horror movie set in a haunted house").with_metadata(HashMap::from([
("genre".into(), json!("horror")),
("year".into(), json!(2022)),
("rating".into(), json!(5.9)),
])),
]
}
fn movie_attribute_info() -> Vec<AttributeInfo> {
vec![
AttributeInfo::new("genre", "string", "The genre of the movie"),
AttributeInfo::new("year", "integer", "The release year"),
AttributeInfo::new("rating", "float", "The movie rating (0-10)"),
]
}
#[tokio::test]
async fn test_self_query_with_eq_filter() {
let embeddings = make_embeddings();
let store = Arc::new(InMemoryVectorStore::new(embeddings));
store.add_documents(movie_docs(), None).await.unwrap();
let llm_response = r#"{"query": "movie", "filter": {"operator": "eq", "field": "genre", "value": "sci-fi"}}"#;
let llm = fake_llm(vec![llm_response]);
let retriever = SelfQueryRetriever::builder()
.vectorstore(store)
.llm(llm)
.document_contents("A collection of movies")
.attribute_info(movie_attribute_info())
.k(10)
.build();
let docs = retriever
.get_relevant_documents("sci-fi movies")
.await
.unwrap();
assert_eq!(docs.len(), 1);
assert_eq!(docs[0].metadata.get("genre").unwrap(), "sci-fi");
}
#[tokio::test]
async fn test_self_query_with_gt_filter() {
let embeddings = make_embeddings();
let store = Arc::new(InMemoryVectorStore::new(embeddings));
store.add_documents(movie_docs(), None).await.unwrap();
let llm_response =
r#"{"query": "movie", "filter": {"operator": "gt", "field": "year", "value": 2020}}"#;
let llm = fake_llm(vec![llm_response]);
let retriever = SelfQueryRetriever::builder()
.vectorstore(store)
.llm(llm)
.document_contents("A collection of movies")
.attribute_info(movie_attribute_info())
.k(10)
.build();
let docs = retriever
.get_relevant_documents("movies after 2020")
.await
.unwrap();
assert_eq!(docs.len(), 3);
for doc in &docs {
let year = doc.metadata.get("year").unwrap().as_i64().unwrap();
assert!(year > 2020);
}
}
#[tokio::test]
async fn test_self_query_with_and_filter() {
let embeddings = make_embeddings();
let store = Arc::new(InMemoryVectorStore::new(embeddings));
store.add_documents(movie_docs(), None).await.unwrap();
let llm_response = r#"{"query": "movie", "filter": {"operator": "and", "filters": [{"operator": "gt", "field": "year", "value": 2020}, {"operator": "gte", "field": "rating", "value": 7.0}]}}"#;
let llm = fake_llm(vec![llm_response]);
let retriever = SelfQueryRetriever::builder()
.vectorstore(store)
.llm(llm)
.document_contents("A collection of movies")
.attribute_info(movie_attribute_info())
.k(10)
.build();
let docs = retriever
.get_relevant_documents("good movies after 2020")
.await
.unwrap();
assert_eq!(docs.len(), 2);
for doc in &docs {
let year = doc.metadata.get("year").unwrap().as_i64().unwrap();
let rating = doc.metadata.get("rating").unwrap().as_f64().unwrap();
assert!(year > 2020);
assert!(rating >= 7.0);
}
}
#[tokio::test]
async fn test_self_query_no_filter() {
let embeddings = make_embeddings();
let store = Arc::new(InMemoryVectorStore::new(embeddings));
store.add_documents(movie_docs(), None).await.unwrap();
let llm_response = r#"{"query": "thriller movie", "filter": null}"#;
let llm = fake_llm(vec![llm_response]);
let retriever = SelfQueryRetriever::builder()
.vectorstore(store)
.llm(llm)
.document_contents("A collection of movies")
.attribute_info(movie_attribute_info())
.k(10)
.build();
let docs = retriever
.get_relevant_documents("thriller movies")
.await
.unwrap();
assert_eq!(docs.len(), 4);
}
#[tokio::test]
async fn test_self_query_with_in_filter() {
let embeddings = make_embeddings();
let store = Arc::new(InMemoryVectorStore::new(embeddings));
store.add_documents(movie_docs(), None).await.unwrap();
let llm_response = r#"{"query": "movie", "filter": {"operator": "in", "field": "genre", "values": ["sci-fi", "action"]}}"#;
let llm = fake_llm(vec![llm_response]);
let retriever = SelfQueryRetriever::builder()
.vectorstore(store)
.llm(llm)
.document_contents("A collection of movies")
.attribute_info(movie_attribute_info())
.k(10)
.build();
let docs = retriever
.get_relevant_documents("sci-fi or action movies")
.await
.unwrap();
assert_eq!(docs.len(), 2);
for doc in &docs {
let genre = doc.metadata.get("genre").unwrap().as_str().unwrap();
assert!(genre == "sci-fi" || genre == "action");
}
}
#[tokio::test]
async fn test_self_query_with_not_filter() {
let embeddings = make_embeddings();
let store = Arc::new(InMemoryVectorStore::new(embeddings));
store.add_documents(movie_docs(), None).await.unwrap();
let llm_response = r#"{"query": "movie", "filter": {"operator": "not", "filter": {"operator": "eq", "field": "genre", "value": "horror"}}}"#;
let llm = fake_llm(vec![llm_response]);
let retriever = SelfQueryRetriever::builder()
.vectorstore(store)
.llm(llm)
.document_contents("A collection of movies")
.attribute_info(movie_attribute_info())
.k(10)
.build();
let docs = retriever
.get_relevant_documents("non-horror movies")
.await
.unwrap();
assert_eq!(docs.len(), 3);
for doc in &docs {
let genre = doc.metadata.get("genre").unwrap().as_str().unwrap();
assert_ne!(genre, "horror");
}
}
#[tokio::test]
async fn test_self_query_with_or_filter() {
let embeddings = make_embeddings();
let store = Arc::new(InMemoryVectorStore::new(embeddings));
store.add_documents(movie_docs(), None).await.unwrap();
let llm_response = r#"{"query": "movie", "filter": {"operator": "or", "filters": [{"operator": "eq", "field": "genre", "value": "comedy"}, {"operator": "eq", "field": "genre", "value": "horror"}]}}"#;
let llm = fake_llm(vec![llm_response]);
let retriever = SelfQueryRetriever::builder()
.vectorstore(store)
.llm(llm)
.document_contents("A collection of movies")
.attribute_info(movie_attribute_info())
.k(10)
.build();
let docs = retriever
.get_relevant_documents("comedy or horror movies")
.await
.unwrap();
assert_eq!(docs.len(), 2);
for doc in &docs {
let genre = doc.metadata.get("genre").unwrap().as_str().unwrap();
assert!(genre == "comedy" || genre == "horror");
}
}
#[tokio::test]
async fn test_self_query_filter_disabled() {
let embeddings = make_embeddings();
let store = Arc::new(InMemoryVectorStore::new(embeddings));
store.add_documents(movie_docs(), None).await.unwrap();
let llm_response = r#"{"query": "movie", "filter": {"operator": "eq", "field": "genre", "value": "sci-fi"}}"#;
let llm = fake_llm(vec![llm_response]);
let retriever = SelfQueryRetriever::builder()
.vectorstore(store)
.llm(llm)
.document_contents("A collection of movies")
.attribute_info(movie_attribute_info())
.k(10)
.enable_filter(false)
.build();
let docs = retriever
.get_relevant_documents("sci-fi movies")
.await
.unwrap();
assert_eq!(docs.len(), 4);
}
#[test]
fn test_attribute_filter_eq_matches() {
let metadata = HashMap::from([("genre".into(), json!("sci-fi"))]);
let filter = AttributeFilter::Eq {
field: "genre".into(),
value: FilterValue::String("sci-fi".into()),
};
assert!(filter.matches(&metadata));
let filter_ne = AttributeFilter::Eq {
field: "genre".into(),
value: FilterValue::String("action".into()),
};
assert!(!filter_ne.matches(&metadata));
}
#[test]
fn test_attribute_filter_ne_matches() {
let metadata = HashMap::from([("genre".into(), json!("comedy"))]);
let filter = AttributeFilter::Ne {
field: "genre".into(),
value: FilterValue::String("horror".into()),
};
assert!(filter.matches(&metadata));
}
#[test]
fn test_attribute_filter_numeric_comparisons() {
let metadata = HashMap::from([("year".into(), json!(2022))]);
assert!(AttributeFilter::Gt {
field: "year".into(),
value: FilterValue::Integer(2020)
}
.matches(&metadata));
assert!(!AttributeFilter::Gt {
field: "year".into(),
value: FilterValue::Integer(2022)
}
.matches(&metadata));
assert!(AttributeFilter::Gte {
field: "year".into(),
value: FilterValue::Integer(2022)
}
.matches(&metadata));
assert!(AttributeFilter::Lt {
field: "year".into(),
value: FilterValue::Integer(2025)
}
.matches(&metadata));
assert!(AttributeFilter::Lte {
field: "year".into(),
value: FilterValue::Integer(2022)
}
.matches(&metadata));
}
#[test]
fn test_attribute_filter_missing_field() {
let metadata = HashMap::new();
assert!(!AttributeFilter::Eq {
field: "genre".into(),
value: FilterValue::String("sci-fi".into())
}
.matches(&metadata));
assert!(AttributeFilter::Ne {
field: "genre".into(),
value: FilterValue::String("sci-fi".into())
}
.matches(&metadata));
assert!(AttributeFilter::Nin {
field: "genre".into(),
values: vec![FilterValue::String("sci-fi".into())]
}
.matches(&metadata));
}
#[test]
fn test_parse_structured_query_valid() {
let response = r#"{"query": "sci-fi movies", "filter": {"operator": "eq", "field": "genre", "value": "sci-fi"}}"#;
let result = parse_structured_query(response).unwrap();
assert_eq!(result.query, "sci-fi movies");
assert!(result.filter.is_some());
}
#[test]
fn test_parse_structured_query_with_code_fence() {
let response = "```json\n{\"query\": \"test\", \"filter\": null}\n```";
let result = parse_structured_query(response).unwrap();
assert_eq!(result.query, "test");
assert!(result.filter.is_none());
}
#[test]
fn test_parse_structured_query_invalid_json() {
let response = "this is not json";
let result = parse_structured_query(response);
assert!(result.is_err());
}
}