/* This Source Code Form is subject to the terms of the Mozilla Public *License,v.2.0.IfacopyoftheMPLwasnotdistributedwiththis *file,Youcanobtainoneathttp://mozilla.org/MPL/2.0/.
*/
use rusqlite::named_params; use serde::Deserialize; use sql_support::ConnExt;
#[derive(Clone, Debug, Deserialize)] pub(crate) struct DownloadedWeatherAttachment { /// Weather keywords. pub keywords: Vec<String>, /// Threshold for weather keyword prefix matching when a weather keyword is /// the first term in a query. `None` means prefix matching is disabled and /// weather keywords must be typed in full when they are first in the query. /// This threshold does not apply to city and region names. If there are /// multiple weather records, we use the `min_keyword_length` in the most /// recently ingested record. pub min_keyword_length: Option<i32>, /// Score for weather suggestions. If there are multiple weather records, we /// use the `score` from the most recently ingested record. pub score: Option<f64>, /// The max length of all keywords in the attachment. Used for keyword /// metrics. We pre-compute this to avoid doing duplicate work on all user's /// machines. pub max_keyword_length: u32, /// The max word count of all keywords in the attachment. Used for keyword /// metrics. We pre-compute this to avoid doing duplicate work on all user's /// machines. pub max_keyword_word_count: u32,
}
/// This data is used to service every query handled by the weather provider, so /// we cache it from the DB. #[derive(Debug, Default)] pubstruct WeatherCache { /// Cached value of the same name from `SuggestProviderConfig::Weather`.
min_keyword_length: i32, /// Cached value of the same name from `SuggestProviderConfig::Weather`.
score: f64, /// Max length of all weather keywords.
max_keyword_length: usize, /// Max word count across all weather keywords.
max_keyword_word_count: usize,
}
impl SuggestDao<'_> { /// Fetches weather suggestions. pubfn fetch_weather_suggestions(&self, query: &SuggestionQuery) -> Result<Vec<Suggestion>> { // We'll just stipulate we won't support tiny queries in order to avoid // a bunch of work when the user starts typing a query. if query.keyword.len() < 3 { return Ok(vec![]);
}
// The first step in parsing the query is lowercasing and splitting it // into words. We want to avoid that work for strings that are so long // they can't possibly match. The longest possible weather query is two // geonames + one weather keyword + at least two spaces between those // three components, say, 10 extra characters total for spaces and // punctuation. There's no point in an analogous min length check since // weather suggestions can be matched on city alone and many city names // are only a few characters long ("nyc"). let g_cache = self.geoname_cache(); let w_cache = self.weather_cache(); let max_query_len = 2 * g_cache.max_name_length + w_cache.max_keyword_length + 10; if max_query_len < query.keyword.len() { return Ok(vec![]);
}
let max_chunk_size =
std::cmp::max(g_cache.max_name_word_count, w_cache.max_keyword_word_count);
// Lowercase, strip punctuation, and split the query into words. let kw_lower = query.keyword.to_lowercase(); let words: Vec<_> = kw_lower
.split_whitespace()
.flat_map(|w| {
w.split(|c| !char::is_alphabetic(c))
.filter(|s| !s.is_empty())
})
.collect();
letmut matches = // Step 2: Parse the query words into a list of token paths.
filter_map_chunks::<Token>(&words, max_chunk_size, |chunk, chunk_i, path| { // Match the chunk to token types that haven't already been matched // in this path. `all_tokens` will remain `None` until a token is // matched. letmut all_tokens: Option<Vec<Token>> = None; for tt in [
TokenType::City,
TokenType::Region,
TokenType::WeatherKeyword,
] { if !path.iter().any(|t| t.token_type() == tt) { letmut tokens = self.match_weather_tokens(tt, path, chunk, chunk_i == 0)?; if !tokens.is_empty() { letmut ts = all_tokens.take().unwrap_or_default();
ts.append(&mut tokens);
all_tokens.replace(ts);
}
}
} // If no tokens were matched, `all_tokens` will be `None`.
Ok(all_tokens)
})?
.into_iter() // Step 3: Map each token path to a city-region-keyword tuple (each // optional). Paths are vecs, so they're ordered, so we may end up // with duplicate tuples after this step. e.g., the paths // `[<Waterloo IA>, <IA>]` and `[<IA>, <Waterloo IA>]` map to the // same `(<Waterloo IA>, <IA>, None)` tuple.
.map(|path| {
path.into_iter()
.fold((None, None, None), |mut match_tuple, token| { match token {
Token::City(c) => {
match_tuple.0 = Some(c);
}
Token::Region(r) => {
match_tuple.1 = Some(r);
}
Token::WeatherKeyword(kw) => {
match_tuple.2 = Some(kw);
}
}
match_tuple
})
}) // Step 4: Discard tuples that don't have the right combination of // tokens or that are otherwise invalid. Along with step 2, this is // the core of the matching logic. In general, allow a tuple if it // has (a) a city name typed in full or (b) a weather keyword at // least as long as the config's min keyword length, since that // indicates a weather intent.
.filter(|(city_match, region_match, kw_match)| { match (city_match, region_match, kw_match) {
(None, None, Some(_)) => true,
(None, _, None) | (None, Some(_), Some(_)) => false,
(Some(city), region, kw) => {
(city.match_type.is_name() && !city.prefix) // Allow city abbreviations without a weather // keyword but only if the region was typed in full.
|| (city.match_type.is_abbreviation()
&& !city.prefix
&& region.as_ref().map(|r| !r.prefix).unwrap_or(false))
|| kw.as_ref().map(|k| k.is_min_keyword_length).unwrap_or(false)
}
}
}) // Step 5: Map each tuple to a city-region tuple: Convert geoname // matches to their `Geoname` values and discard keywords. // Discarding keywords is important because we'll collect the tuples // in a set in the next step in order to dedupe city-regions.
.map(|(city, region, _)| {
(city.map(|c| c.geoname), region.map(|r| r.geoname))
}) // Step 6: Dedupe the city-regions by collecting them in a set.
.collect::<HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
// Sort the matches so cities with larger populations are first.
matches.sort_by(
|(city1, region1), (city2, region2)| match (&city1, &city2) {
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(Some(c1), Some(c2)) => c2.population.cmp(&c1.population),
(None, None) => match (®ion1, ®ion2) {
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(Some(r1), Some(r2)) => r2.population.cmp(&r1.population),
(None, None) => Ordering::Equal,
},
},
);
fn match_weather_tokens(
&self,
token_type: TokenType,
path: &[Token],
candidate: &str,
is_first_chunk: bool,
) -> Result<Vec<Token>> { match token_type {
TokenType::City => { // Fetch matching cities, and filter them to regions we've // already matched in this path. let regions: Vec<_> = path
.iter()
.filter_map(|t| t.region().map(|m| &m.geoname))
.collect();
Ok(self
.fetch_geonames(
candidate,
!is_first_chunk,
Some(GeonameType::City), if regions.is_empty() {
None
} else {
Some(regions)
},
)?
.into_iter()
.map(Token::City)
.collect())
}
TokenType::Region => { // Fetch matching regions, and filter them to cities we've // already matched in this patch. let cities: Vec<_> = path
.iter()
.filter_map(|t| t.city().map(|m| &m.geoname))
.collect();
Ok(self
.fetch_geonames(
candidate,
!is_first_chunk,
Some(GeonameType::Region), if cities.is_empty() {
None
} else {
Some(cities)
},
)?
.into_iter()
.map(Token::Region)
.collect())
}
TokenType::WeatherKeyword => { // Fetch matching keywords. `min_keyword_length == 0` in the // config means that the config doesn't allow prefix matching. // `min_keyword_length > 0` means that the keyword must be at // least that long when there's not already a city name present // in the query. let len = self.weather_cache().min_keyword_length; if is_first_chunk && (candidate.len() as i32) < len { // The candidate is the first term in the query and it's too // short.
Ok(vec![])
} else { // Do arbitrary prefix matching if the candidate isn't the // first term in the query or if the config allows prefix // matching.
Ok(self
.match_weather_keywords(candidate, !is_first_chunk || len > 0)?
.into_iter()
.map(|keyword| {
Token::WeatherKeyword(WeatherKeywordMatch {
keyword,
is_min_keyword_length: (len as usize) <= candidate.len(),
})
})
.collect())
}
}
}
}
fn match_weather_keywords(&self, candidate: &str, prefix: bool) -> Result<Vec<String>> { self.conn.query_rows_and_then_cached(
r#"
SELECT
k.keyword,
s.score,
k.keyword != :keyword AS matched_prefix
FROM
suggestions s
JOIN
keywords k
ON k.suggestion_id = s.id WHERE
s.provider = :provider
AND (
CASE :prefix WHEN FALSE THEN k.keyword = :keyword ELSE (k.keyword BETWEEN :keyword AND :keyword || X'FFFF') END
) "#,
named_params! { ":prefix": prefix, ":keyword": candidate, ":provider": SuggestionProvider::Weather
},
|row| -> Result<String> { Ok(row.get("keyword")?) },
)
}
/// Inserts weather suggestions data into the database. fn insert_weather_data(
&mutself,
record_id: &SuggestRecordId,
attachments: &[DownloadedWeatherAttachment],
) -> Result<()> { self.scope.err_if_interrupted()?; letmut suggestion_insert = SuggestionInsertStatement::new(self.conn)?; letmut keyword_insert = KeywordInsertStatement::new(self.conn)?; letmut keyword_metrics_insert = KeywordMetricsInsertStatement::new(self.conn)?; letmut max_len = 0; letmut max_word_count = 0; for attach in attachments { let suggestion_id = suggestion_insert.execute(
record_id, "", "",
attach.score.unwrap_or(DEFAULT_SUGGESTION_SCORE),
SuggestionProvider::Weather,
)?; for (i, keyword) in attach.keywords.iter().enumerate() {
keyword_insert.execute(suggestion_id, keyword, None, i)?;
} self.put_provider_config(SuggestionProvider::Weather, &attach.into())?;
max_len = std::cmp::max(max_len, attach.max_keyword_length as usize);
max_word_count = std::cmp::max(max_word_count, attach.max_keyword_word_count as usize);
}
// We just made some insertions that might invalidate the data in the // cache. Clear it so it's repopulated the next time it's accessed. self.weather_cache.take();
letmut store = geoname::tests::new_test_store();
store.client_mut().add_record( "weather", "weather-1",
json!({ // Include a keyword that's a prefix of another keyword -- // "weather" and "weather near me" -- so that when a test // matches both we can verify only one suggestion is returned, // not two. "keywords": ["ab", "xyz", "weather", "weather near me"], "min_keyword_length": 5, "max_keyword_length": "weather".len(), "max_keyword_word_count": 1, "score": 0.24
}),
);
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.