diff --git a/crates/meilisearch-types/src/error.rs b/crates/meilisearch-types/src/error.rs index 458034c00..2ddc3d0d0 100644 --- a/crates/meilisearch-types/src/error.rs +++ b/crates/meilisearch-types/src/error.rs @@ -310,6 +310,9 @@ InvalidSearchShowRankingScoreDetails , InvalidRequest , BAD_REQU InvalidSimilarShowRankingScoreDetails , InvalidRequest , BAD_REQUEST ; InvalidSearchSort , InvalidRequest , BAD_REQUEST ; InvalidSearchDistinct , InvalidRequest , BAD_REQUEST ; +InvalidSearchPersonalization , InvalidRequest , BAD_REQUEST ; +InvalidSearchPersonalizationPersonalized , InvalidRequest , BAD_REQUEST ; +InvalidSearchPersonalizationUserProfile , InvalidRequest , BAD_REQUEST ; InvalidSearchMediaAndVector , InvalidRequest , BAD_REQUEST ; InvalidSettingsDisplayedAttributes , InvalidRequest , BAD_REQUEST ; InvalidSettingsDistinctAttribute , InvalidRequest , BAD_REQUEST ; @@ -650,6 +653,24 @@ impl fmt::Display for deserr_codes::InvalidNetworkSearchApiKey { } } +impl fmt::Display for deserr_codes::InvalidSearchPersonalization { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "the value of `personalization` is invalid, expected a JSON object with `personalized` boolean and optional `userProfile` string.") + } +} + +impl fmt::Display for deserr_codes::InvalidSearchPersonalizationPersonalized { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "the value of `personalized` is invalid, expected a boolean.") + } +} + +impl fmt::Display for deserr_codes::InvalidSearchPersonalizationUserProfile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "the value of `userProfile` is invalid, expected a string.") + } +} + #[macro_export] macro_rules! internal_error { ($target:ty : $($other:path), *) => { diff --git a/crates/meilisearch/src/routes/indexes/facet_search.rs b/crates/meilisearch/src/routes/indexes/facet_search.rs index 18ad54ccf..8a95f5fc5 100644 --- a/crates/meilisearch/src/routes/indexes/facet_search.rs +++ b/crates/meilisearch/src/routes/indexes/facet_search.rs @@ -343,6 +343,7 @@ impl From for SearchQuery { hybrid, ranking_score_threshold, locales, + personalization: None, } } } diff --git a/crates/meilisearch/src/routes/indexes/search.rs b/crates/meilisearch/src/routes/indexes/search.rs index 697ae9241..3e1b59f4b 100644 --- a/crates/meilisearch/src/routes/indexes/search.rs +++ b/crates/meilisearch/src/routes/indexes/search.rs @@ -22,10 +22,10 @@ use crate::extractors::sequential_extractor::SeqHandler; use crate::metrics::MEILISEARCH_DEGRADED_SEARCH_REQUESTS; use crate::routes::indexes::search_analytics::{SearchAggregator, SearchGET, SearchPOST}; use crate::search::{ - add_search_rules, perform_search, HybridQuery, MatchingStrategy, RankingScoreThreshold, - RetrieveVectors, SearchKind, SearchQuery, SearchResult, SemanticRatio, DEFAULT_CROP_LENGTH, - DEFAULT_CROP_MARKER, DEFAULT_HIGHLIGHT_POST_TAG, DEFAULT_HIGHLIGHT_PRE_TAG, - DEFAULT_SEARCH_LIMIT, DEFAULT_SEARCH_OFFSET, DEFAULT_SEMANTIC_RATIO, + add_search_rules, perform_search, HybridQuery, MatchingStrategy, Personalization, + RankingScoreThreshold, RetrieveVectors, SearchKind, SearchQuery, SearchResult, SemanticRatio, + DEFAULT_CROP_LENGTH, DEFAULT_CROP_MARKER, DEFAULT_HIGHLIGHT_POST_TAG, + DEFAULT_HIGHLIGHT_PRE_TAG, DEFAULT_SEARCH_LIMIT, DEFAULT_SEARCH_OFFSET, DEFAULT_SEMANTIC_RATIO, }; use crate::search_queue::SearchQueue; @@ -132,6 +132,11 @@ pub struct SearchQueryGet { #[deserr(default, error = DeserrQueryParamError)] #[param(value_type = Vec, explode = false)] pub locales: Option>, + #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = bool)] + pub personalization_personalized: Option, + #[deserr(default, error = DeserrQueryParamError)] + pub personalization_user_profile: Option, } #[derive(Debug, Clone, Copy, PartialEq, deserr::Deserr)] @@ -203,6 +208,22 @@ impl TryFrom for SearchQuery { )); } + let personalization = match ( + other.personalization_personalized, + other.personalization_user_profile, + ) { + (None, None) => None, + (Some(personalized), user_profile) => { + Some(Personalization { personalized, user_profile }) + } + (None, Some(_)) => { + return Err(ResponseError::from_msg( + "`personalizationPersonalized` is mandatory when `personalizationUserProfile` is present".into(), + meilisearch_types::error::Code::InvalidSearchPersonalization, + )); + } + }; + Ok(Self { q: other.q, // `media` not supported for `GET` @@ -232,6 +253,7 @@ impl TryFrom for SearchQuery { hybrid, ranking_score_threshold: other.ranking_score_threshold.map(|o| o.0), locales: other.locales.map(|o| o.into_iter().collect()), + personalization, }) } } diff --git a/crates/meilisearch/src/routes/indexes/search_analytics.rs b/crates/meilisearch/src/routes/indexes/search_analytics.rs index 07f79eba7..6b03c30ec 100644 --- a/crates/meilisearch/src/routes/indexes/search_analytics.rs +++ b/crates/meilisearch/src/routes/indexes/search_analytics.rs @@ -128,6 +128,7 @@ impl SearchAggregator { hybrid, ranking_score_threshold, locales, + personalization: _, } = query; let mut ret = Self::default(); diff --git a/crates/meilisearch/src/routes/multi_search_analytics.rs b/crates/meilisearch/src/routes/multi_search_analytics.rs index c24875797..ab72d5911 100644 --- a/crates/meilisearch/src/routes/multi_search_analytics.rs +++ b/crates/meilisearch/src/routes/multi_search_analytics.rs @@ -67,6 +67,7 @@ impl MultiSearchAggregator { hybrid: _, ranking_score_threshold: _, locales: _, + personalization: _, } in &federated_search.queries { if let Some(federation_options) = federation_options { diff --git a/crates/meilisearch/src/search/mod.rs b/crates/meilisearch/src/search/mod.rs index 93efad67f..14a6dae82 100644 --- a/crates/meilisearch/src/search/mod.rs +++ b/crates/meilisearch/src/search/mod.rs @@ -57,6 +57,15 @@ pub const DEFAULT_HIGHLIGHT_PRE_TAG: fn() -> String = || "".to_string(); pub const DEFAULT_HIGHLIGHT_POST_TAG: fn() -> String = || "".to_string(); pub const DEFAULT_SEMANTIC_RATIO: fn() -> SemanticRatio = || SemanticRatio(0.5); +#[derive(Clone, Default, PartialEq, Deserr, ToSchema, Debug)] +#[deserr(error = DeserrJsonError, rename_all = camelCase, deny_unknown_fields)] +pub struct Personalization { + #[deserr(default, error = DeserrJsonError)] + pub personalized: bool, + #[deserr(default, error = DeserrJsonError)] + pub user_profile: Option, +} + #[derive(Clone, Default, PartialEq, Deserr, ToSchema)] #[deserr(error = DeserrJsonError, rename_all = camelCase, deny_unknown_fields)] pub struct SearchQuery { @@ -120,6 +129,8 @@ pub struct SearchQuery { pub ranking_score_threshold: Option, #[deserr(default, error = DeserrJsonError)] pub locales: Option>, + #[deserr(default, error = DeserrJsonError, default)] + pub personalization: Option, } impl From for SearchQuery { @@ -167,6 +178,7 @@ impl From for SearchQuery { highlight_post_tag: DEFAULT_HIGHLIGHT_POST_TAG(), crop_marker: DEFAULT_CROP_MARKER(), locales: None, + personalization: None, } } } @@ -248,6 +260,7 @@ impl fmt::Debug for SearchQuery { attributes_to_search_on, ranking_score_threshold, locales, + personalization, } = self; let mut debug = f.debug_struct("SearchQuery"); @@ -336,6 +349,10 @@ impl fmt::Debug for SearchQuery { debug.field("locales", &locales); } + if let Some(personalization) = personalization { + debug.field("personalization", &personalization); + } + debug.finish() } } @@ -541,6 +558,9 @@ pub struct SearchQueryWithIndex { pub ranking_score_threshold: Option, #[deserr(default, error = DeserrJsonError, default)] pub locales: Option>, + #[deserr(default, error = DeserrJsonError, default)] + #[serde(skip)] + pub personalization: Option, #[deserr(default)] pub federation_options: Option, @@ -598,6 +618,7 @@ impl SearchQueryWithIndex { attributes_to_search_on, ranking_score_threshold, locales, + personalization, } = query; SearchQueryWithIndex { @@ -629,6 +650,7 @@ impl SearchQueryWithIndex { attributes_to_search_on, ranking_score_threshold, locales, + personalization, federation_options, } } @@ -664,6 +686,7 @@ impl SearchQueryWithIndex { hybrid, ranking_score_threshold, locales, + personalization, } = self; ( index_uid, @@ -695,6 +718,7 @@ impl SearchQueryWithIndex { hybrid, ranking_score_threshold, locales, + personalization, // do not use ..Default::default() here, // rather add any missing field from `SearchQuery` to `SearchQueryWithIndex` }, @@ -919,7 +943,7 @@ pub struct SearchResultWithIndex { pub result: SearchResult, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, ToSchema)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)] #[serde(untagged)] pub enum HitsInfo { #[serde(rename_all = "camelCase")] @@ -1166,6 +1190,7 @@ pub fn perform_search( attributes_to_search_on: _, filter: _, distinct: _, + personalization: _, } = query; let format = AttributesFormat {