use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub struct EntityCandidate {
pub text: String,
pub normalized: String,
pub start: usize,
pub end: usize,
}
pub fn extract_entities(text: &str, aliases: &BTreeMap<String, String>) -> Vec<EntityCandidate> {
let mut entities = Vec::new();
let entities_from_capitalization = extract_capitalized_entities(text);
entities.extend(entities_from_capitalization);
let pronouns = extract_pronouns(text);
for pronoun in pronouns {
if let Some(antecedent) = find_pronoun_antecedent(text, &pronoun) {
entities.push(EntityCandidate {
text: pronoun.text.clone(),
normalized: antecedent,
start: pronoun.start,
end: pronoun.end,
});
}
}
for entity in &mut entities {
if let Some(canonical) = aliases.get(&entity.text) {
entity.normalized = canonical.clone();
}
}
entities.sort_by_key(|e| e.start);
entities
}
#[rustfmt::skip]
const SENTENCE_OPENERS: &[&str] = &[
"a", "after", "again", "all", "although", "an", "and", "another", "any", "are", "as", "at",
"because", "before", "both", "but", "by", "did", "do", "does", "each", "either", "even",
"every", "for", "from", "had", "has", "have", "he", "her", "here", "him", "his", "how",
"however", "if",
"in", "indeed", "instead", "is", "its", "just", "later", "maybe", "meanwhile", "my", "neither",
"never", "no", "nor", "not", "now", "of", "often", "on", "once", "only", "or", "our",
"perhaps", "since", "so", "some", "sometimes", "soon", "still", "suddenly", "that", "the",
"she", "their", "them", "then", "there", "these", "they", "this", "those", "though", "to",
"today", "tomorrow",
"tonight", "was", "were", "what", "when", "where", "which", "while", "who", "whom", "whose",
"why", "with", "yesterday", "yet", "your",
];
const NAME_PARTICLES: &[&str] = &[
"de", "del", "della", "der", "des", "di", "du", "la", "le", "van", "von", "af", "ter", "bin",
"ibn", "al", "da", "dos", "das", "y",
];
fn is_sentence_start(text: &str, word_start: usize) -> bool {
let mut quoted = false;
for c in text[..word_start].chars().rev() {
if c.is_whitespace() {
continue;
}
if "\"'\u{201C}\u{2018}([".contains(c) {
quoted = true;
continue;
}
return ".!?\u{2026}".contains(c) || (quoted && c == ',');
}
true
}
fn opens_sentence_by_position(text: &str, word: &str, word_start: usize) -> bool {
SENTENCE_OPENERS.contains(&word.to_lowercase().as_str()) && is_sentence_start(text, word_start)
}
fn extract_capitalized_entities(text: &str) -> Vec<EntityCandidate> {
let mut entities = Vec::new();
let mut current_entity = String::new();
let mut start_idx = 0;
let mut current_word = String::new();
let mut word_start = 0;
let mut entity_end = 0;
let mut pending_particles = String::new();
for (byte_pos, c) in text.char_indices() {
let is_sep = c.is_whitespace() || ",.!?;:—'\"".contains(c);
if is_sep {
if !current_word.is_empty() {
let is_capitalized = current_word
.chars()
.next()
.is_some_and(|c| c.is_uppercase())
&& !(current_entity.is_empty()
&& opens_sentence_by_position(text, ¤t_word, word_start));
if is_capitalized {
if !current_entity.is_empty() {
current_entity.push(' ');
current_entity.push_str(&pending_particles);
} else {
start_idx = word_start;
}
pending_particles.clear();
current_entity.push_str(¤t_word);
entity_end = byte_pos;
} else if !current_entity.is_empty()
&& c.is_whitespace()
&& NAME_PARTICLES.contains(¤t_word.to_lowercase().as_str())
{
pending_particles.push_str(¤t_word);
pending_particles.push(' ');
} else {
if !current_entity.is_empty() {
entities.push(EntityCandidate {
text: current_entity.clone(),
normalized: normalize_entity(¤t_entity),
start: start_idx,
end: entity_end,
});
current_entity.clear();
}
pending_particles.clear();
}
current_word.clear();
}
} else {
if current_word.is_empty() {
word_start = byte_pos;
}
current_word.push(c);
}
}
if !current_word.is_empty() {
let is_capitalized = current_word
.chars()
.next()
.is_some_and(|c| c.is_uppercase())
&& !(current_entity.is_empty()
&& opens_sentence_by_position(text, ¤t_word, word_start));
if is_capitalized {
if !current_entity.is_empty() {
current_entity.push(' ');
current_entity.push_str(&pending_particles);
} else {
start_idx = word_start;
}
current_entity.push_str(¤t_word);
entity_end = text.len();
} else if !current_entity.is_empty() {
entities.push(EntityCandidate {
text: current_entity.clone(),
normalized: normalize_entity(¤t_entity),
start: start_idx,
end: entity_end,
});
current_entity.clear();
}
}
if !current_entity.is_empty() {
entities.push(EntityCandidate {
text: current_entity.clone(),
normalized: normalize_entity(¤t_entity),
start: start_idx,
end: entity_end,
});
}
entities
}
#[derive(Debug)]
struct Pronoun {
text: String,
start: usize,
end: usize,
}
fn extract_pronouns(text: &str) -> Vec<Pronoun> {
let pronouns = [
"he", "she", "they", "him", "her", "them", "his", "their", "it",
];
let mut found = Vec::new();
let mut word_start: Option<usize> = None;
let sentinel = std::iter::once((text.len(), ' '));
for (byte_pos, c) in text.char_indices().chain(sentinel) {
if c.is_alphabetic() {
word_start.get_or_insert(byte_pos);
continue;
}
if let Some(start) = word_start.take() {
let word = &text[start..byte_pos];
if let Some(pronoun) = pronouns.iter().find(|p| word.eq_ignore_ascii_case(p)) {
found.push(Pronoun {
text: (*pronoun).to_string(),
start,
end: byte_pos,
});
}
}
}
found
}
fn find_pronoun_antecedent(text: &str, pronoun: &Pronoun) -> Option<String> {
let before_pronoun = &text[..pronoun.start];
let words: Vec<&str> = before_pronoun.split_whitespace().collect();
for word in words.iter().rev() {
let word = word.split(['\'', '\u{2019}']).next().unwrap_or(word);
let word = word.trim_matches(|c: char| !c.is_alphanumeric());
if word.chars().next().is_some_and(|c| c.is_uppercase()) && word.len() > 1 {
return Some(normalize_entity(word));
}
}
None
}
fn normalize_entity(text: &str) -> String {
text.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join("_")
}