use std::collections::HashSet;
use yake_rust::{get_n_best, Config, StopWords};
#[derive(Debug, Clone)]
pub struct KeywordConfig {
pub max_keywords: usize,
pub ngrams: usize,
pub min_length: usize,
pub language: String,
pub dedup_threshold: f64,
}
impl Default for KeywordConfig {
fn default() -> Self {
Self {
max_keywords: 10,
ngrams: 2,
min_length: 3,
language: "en".to_string(),
dedup_threshold: 0.9,
}
}
}
#[derive(Debug, Clone)]
pub struct Keyword {
pub text: String,
pub score: f64,
pub importance: f32,
}
pub struct KeywordExtractor {
config: KeywordConfig,
stopwords: StopWords,
}
impl KeywordExtractor {
pub fn new() -> Self {
Self::with_config(KeywordConfig::default())
}
pub fn with_config(config: KeywordConfig) -> Self {
let stopwords = StopWords::predefined(&config.language)
.or_else(|| StopWords::predefined("en"))
.unwrap_or_else(|| StopWords::custom(HashSet::new()));
Self { config, stopwords }
}
pub fn extract(&self, text: &str) -> Vec<Keyword> {
if text.trim().is_empty() {
return Vec::new();
}
let punctuation: HashSet<char> = [
'!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/', ':', ';',
'<', '=', '>', '?', '@', '[', '\\', ']', '^', '_', '`', '{', '|', '}', '~',
]
.into_iter()
.collect();
let yake_config = Config {
ngrams: self.config.ngrams,
punctuation,
remove_duplicates: true,
deduplication_threshold: self.config.dedup_threshold,
minimum_chars: self.config.min_length,
..Config::default()
};
let results = get_n_best(
self.config.max_keywords,
text,
&self.stopwords,
&yake_config,
);
let mut keywords: Vec<Keyword> = results
.into_iter()
.map(|item| {
let importance = (1.0 / (1.0 + item.score)) as f32;
Keyword {
text: item.keyword, score: item.score,
importance,
}
})
.collect();
keywords.sort_by(|a, b| b.importance.total_cmp(&a.importance));
keywords
}
pub fn extract_texts(&self, text: &str) -> Vec<String> {
self.extract(text).into_iter().map(|k| k.text).collect()
}
pub fn extract_filtered(&self, text: &str, min_importance: f32) -> Vec<Keyword> {
self.extract(text)
.into_iter()
.filter(|k| k.importance >= min_importance)
.collect()
}
}
impl Default for KeywordExtractor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_basic() {
let extractor = KeywordExtractor::new();
let text = "Caroline painted a beautiful sunrise over the lake yesterday morning.";
let keywords = extractor.extract(text);
assert!(!keywords.is_empty());
let texts: Vec<&str> = keywords.iter().map(|k| k.text.as_str()).collect();
assert!(
texts.contains(&"sunrise") || texts.contains(&"beautiful sunrise"),
"Should extract 'sunrise': {texts:?}"
);
}
#[test]
fn test_extract_texts() {
let extractor = KeywordExtractor::new();
let text = "The quick brown fox jumps over the lazy dog near the river.";
let texts = extractor.extract_texts(text);
assert!(!texts.is_empty());
for t in &texts {
assert_eq!(t.to_lowercase(), *t);
}
}
#[test]
fn test_empty_text() {
let extractor = KeywordExtractor::new();
let keywords = extractor.extract("");
assert!(keywords.is_empty());
}
#[test]
fn test_importance_ordering() {
let extractor = KeywordExtractor::new();
let text =
"Machine learning and artificial intelligence are transforming computer science.";
let keywords = extractor.extract(text);
for i in 1..keywords.len() {
assert!(keywords[i - 1].importance >= keywords[i].importance);
}
}
#[test]
fn test_filter_by_importance() {
let extractor = KeywordExtractor::new();
let text = "The conference discussed various topics including climate change and renewable energy.";
let filtered = extractor.extract_filtered(text, 0.5);
for k in filtered {
assert!(k.importance >= 0.5);
}
}
#[test]
fn test_custom_config() {
let config = KeywordConfig {
max_keywords: 5,
ngrams: 3,
min_length: 4,
..Default::default()
};
let extractor = KeywordExtractor::with_config(config);
let text = "Natural language processing enables computers to understand human language.";
let keywords = extractor.extract(text);
assert!(keywords.len() <= 5);
for k in &keywords {
assert!(k.text.chars().count() >= 4);
}
}
}