#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttrClaim {
pub subject: String,
pub rel: String,
pub value: String,
}
const COPULAS: &[&str] = &[" is ", " are ", " was ", " were "];
const VALUE_FILLERS: &[&str] = &[
"now",
"currently",
"presently",
"still",
"also",
"the",
"a",
"an",
];
const MAX_PHRASE_WORDS: usize = 6;
pub fn extract_attribute_value_claims(text: &str) -> Vec<AttrClaim> {
text.split(|c| matches!(c, '.' | '!' | '?' | ';' | '\n'))
.filter_map(extract_one)
.collect()
}
fn extract_one(sentence: &str) -> Option<AttrClaim> {
let trimmed = sentence.trim();
if trimmed.is_empty() {
return None;
}
let padded = format!(" {} ", trimmed.to_lowercase());
let (cop_idx, cop_len) = COPULAS
.iter()
.filter_map(|c| padded.find(c).map(|i| (i, c.len())))
.min_by_key(|(i, _)| *i)?;
let subject = normalize_phrase(&padded[..cop_idx], &[]);
let value = normalize_phrase(&padded[cop_idx + cop_len..], VALUE_FILLERS);
if subject.is_empty() || value.is_empty() {
return None;
}
if subject.split_whitespace().count() > MAX_PHRASE_WORDS
|| value.split_whitespace().count() > MAX_PHRASE_WORDS
{
return None;
}
if !subject.chars().any(|c| c.is_alphabetic()) {
return None;
}
Some(AttrClaim {
subject,
rel: "is".to_string(),
value,
})
}
fn normalize_phrase(raw: &str, strip_leading: &[&str]) -> String {
let mut words: Vec<String> = raw
.split_whitespace()
.filter(|w| !w.starts_with('#'))
.map(|w| {
w.trim_matches(|c: char| !c.is_alphanumeric() && c != '-')
.to_string()
})
.filter(|w| !w.is_empty())
.collect();
while let Some(first) = words.first() {
if strip_leading.contains(&first.as_str()) {
words.remove(0);
} else {
break;
}
}
words.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
fn one(text: &str) -> Option<AttrClaim> {
let v = extract_attribute_value_claims(text);
v.into_iter().next()
}
#[test]
fn extracts_subject_and_value_dropping_hex() {
let c = one("Brand color is blue #1F4E79.").unwrap();
assert_eq!(c.subject, "brand color");
assert_eq!(c.rel, "is");
assert_eq!(c.value, "blue");
}
#[test]
fn strips_value_filler_so_updates_share_subject_and_differ_on_value() {
let blue = one("Brand color is blue #1F4E79.").unwrap();
let green = one("Brand color is now green #2E7D32.").unwrap();
assert_eq!(blue.subject, green.subject);
assert_eq!(blue.rel, green.rel);
assert_ne!(blue.value, green.value);
assert_eq!(green.value, "green");
}
#[test]
fn identical_restatement_normalizes_equal() {
let a = one("Brand color is blue #1F4E79.").unwrap();
let b = one("Brand color is blue.").unwrap();
assert_eq!(a.value, b.value);
}
#[test]
fn handles_other_copulas() {
assert_eq!(one("The deadline was March.").unwrap().value, "march");
assert_eq!(one("Members are five.").unwrap().subject, "members");
}
#[test]
fn no_copula_yields_nothing() {
assert!(one("Just a passing thought about colors").is_none());
}
#[test]
fn rejects_overlong_subject_and_value() {
assert!(one("one two three four five six seven is blue").is_none());
assert!(one("color is one two three four five six seven").is_none());
}
#[test]
fn multiple_sentences_each_extracted() {
let v = extract_attribute_value_claims("Sky is blue. Grass is green.");
assert_eq!(v.len(), 2);
assert_eq!(v[0].value, "blue");
assert_eq!(v[1].value, "green");
}
}