- clippy --all-targets plus a clawhdf5-format feature matrix (parallel, lz4, zstd, pcodec, fast-checksum); fix the accumulated lint backlog in test, bench and feature-gated code (no behaviour changes). - Install python3 + h5py/numpy/netCDF4/xarray in the CI container and set CLAWHDF5_REQUIRE_INTEROP=1, which makes a missing interop dependency a test failure. Every h5py/netCDF4 interop test used to skip silently in CI. Run the #[ignore]d writer_h5py_tests suite explicitly. - cargo bench --no-run so benches can't rot; fix bench.rs and memory_bench.rs, which no longer compiled against the current strategy/consolidation APIs. - Optional fuzz smoke run via CLAWHDF5_FUZZ_SECONDS. - CHANGELOG and docs/known-issues.md updated. Co-Authored-By: Claude Fable 5.1 <[email protected]>
1074 lines
32 KiB
Rust
1074 lines
32 KiB
Rust
//! Lightweight entity extraction from text.
|
|
//!
|
|
//! Rule-based extractors for common entity types. No ML dependencies.
|
|
//! Designed to run on every memory save without perceptible latency.
|
|
|
|
/// Types of entities that can be extracted.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum ExtractedEntityType {
|
|
Person,
|
|
Organization,
|
|
Location,
|
|
Date,
|
|
Technology,
|
|
Project,
|
|
Custom(String),
|
|
}
|
|
|
|
/// A single extracted entity mention.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ExtractedEntity {
|
|
pub text: String,
|
|
pub entity_type: ExtractedEntityType,
|
|
pub start_offset: usize,
|
|
pub end_offset: usize,
|
|
pub confidence: f32,
|
|
}
|
|
|
|
/// Configuration for the entity extractor.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ExtractorConfig {
|
|
/// Minimum confidence threshold for accepting an extraction.
|
|
pub min_confidence: f32,
|
|
/// Whether to extract dates/times.
|
|
pub extract_dates: bool,
|
|
/// Whether to extract capitalized phrases as potential entities.
|
|
pub extract_capitalized: bool,
|
|
/// Whether to extract technology/tool mentions.
|
|
pub extract_technology: bool,
|
|
/// Custom patterns: (literal string, entity type, confidence).
|
|
pub custom_patterns: Vec<(String, ExtractedEntityType, f32)>,
|
|
}
|
|
|
|
impl Default for ExtractorConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
min_confidence: 0.5,
|
|
extract_dates: true,
|
|
extract_capitalized: true,
|
|
extract_technology: true,
|
|
custom_patterns: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Organization suffixes that indicate a capitalized phrase is an org name.
|
|
const ORG_SUFFIXES: &[&str] = &[
|
|
"Inc",
|
|
"Corp",
|
|
"LLC",
|
|
"Ltd",
|
|
"Systems",
|
|
"Technologies",
|
|
"Labs",
|
|
"Solutions",
|
|
"Group",
|
|
"Co",
|
|
"Foundation",
|
|
"Institute",
|
|
"Association",
|
|
];
|
|
|
|
/// Known location suffixes.
|
|
const LOCATION_SUFFIXES: &[&str] = &[
|
|
"City",
|
|
"State",
|
|
"County",
|
|
"Province",
|
|
"Island",
|
|
"Mountain",
|
|
"River",
|
|
"Lake",
|
|
"Bay",
|
|
"Valley",
|
|
"Park",
|
|
"Street",
|
|
"Avenue",
|
|
"Boulevard",
|
|
];
|
|
|
|
/// Built-in technology word list.
|
|
const TECHNOLOGIES: &[&str] = &[
|
|
// Languages
|
|
"Rust",
|
|
"Python",
|
|
"Go",
|
|
"JavaScript",
|
|
"TypeScript",
|
|
"Java",
|
|
"Ruby",
|
|
"Swift",
|
|
"Kotlin",
|
|
"Scala",
|
|
"Haskell",
|
|
"Erlang",
|
|
"Elixir",
|
|
"Clojure",
|
|
"Lua",
|
|
"Perl",
|
|
"PHP",
|
|
"R",
|
|
"Julia",
|
|
"Dart",
|
|
"Zig",
|
|
// C family
|
|
"C++",
|
|
"C#",
|
|
"C",
|
|
// Frameworks
|
|
"React",
|
|
"Vue",
|
|
"Angular",
|
|
"Svelte",
|
|
"Next.js",
|
|
"Nuxt",
|
|
"Remix",
|
|
"Django",
|
|
"Flask",
|
|
"FastAPI",
|
|
"Express",
|
|
"Rails",
|
|
"Spring",
|
|
"Laravel",
|
|
"Actix",
|
|
"Axum",
|
|
"Rocket",
|
|
"Warp",
|
|
// Databases
|
|
"Redis",
|
|
"PostgreSQL",
|
|
"MySQL",
|
|
"MongoDB",
|
|
"SQLite",
|
|
"DynamoDB",
|
|
"Cassandra",
|
|
"Elasticsearch",
|
|
"Neo4j",
|
|
"InfluxDB",
|
|
"CockroachDB",
|
|
"MariaDB",
|
|
"Oracle",
|
|
"MSSQL",
|
|
// Tools / Platforms
|
|
"Docker",
|
|
"Kubernetes",
|
|
"Git",
|
|
"AWS",
|
|
"GCP",
|
|
"Azure",
|
|
"Terraform",
|
|
"Ansible",
|
|
"Helm",
|
|
"Grafana",
|
|
"Prometheus",
|
|
"Kafka",
|
|
"RabbitMQ",
|
|
"Nginx",
|
|
"Apache",
|
|
"Linux",
|
|
"macOS",
|
|
"Windows",
|
|
"HDF5",
|
|
"OpenClaw",
|
|
"clawhdf5",
|
|
// Other
|
|
"GraphQL",
|
|
"gRPC",
|
|
"REST",
|
|
"WebSocket",
|
|
"OAuth",
|
|
"JWT",
|
|
"WASM",
|
|
"WebAssembly",
|
|
"CUDA",
|
|
"OpenGL",
|
|
"Vulkan",
|
|
];
|
|
|
|
/// Common ISO date separators.
|
|
const ISO_SEPS: &[char] = &['-', '/'];
|
|
|
|
/// Relative date keywords (lower-cased for matching).
|
|
const RELATIVE_DATES: &[&str] = &[
|
|
"yesterday",
|
|
"today",
|
|
"tomorrow",
|
|
"last week",
|
|
"next week",
|
|
"this week",
|
|
"last month",
|
|
"next month",
|
|
"this month",
|
|
"last year",
|
|
"next year",
|
|
"this year",
|
|
"recently",
|
|
"soon",
|
|
];
|
|
|
|
/// Month names for English date patterns.
|
|
const MONTHS: &[&str] = &[
|
|
"january",
|
|
"february",
|
|
"march",
|
|
"april",
|
|
"may",
|
|
"june",
|
|
"july",
|
|
"august",
|
|
"september",
|
|
"october",
|
|
"november",
|
|
"december",
|
|
"jan",
|
|
"feb",
|
|
"mar",
|
|
"apr",
|
|
"jun",
|
|
"jul",
|
|
"aug",
|
|
"sep",
|
|
"oct",
|
|
"nov",
|
|
"dec",
|
|
];
|
|
|
|
/// Main entity extractor.
|
|
pub struct EntityExtractor {
|
|
config: ExtractorConfig,
|
|
}
|
|
|
|
impl EntityExtractor {
|
|
/// Create a new extractor with the given configuration.
|
|
pub fn new(config: ExtractorConfig) -> Self {
|
|
Self { config }
|
|
}
|
|
|
|
/// Extract all entities from a text chunk.
|
|
pub fn extract(&self, text: &str) -> Vec<ExtractedEntity> {
|
|
let mut entities: Vec<ExtractedEntity> = Vec::new();
|
|
|
|
if self.config.extract_dates {
|
|
entities.extend(extract_dates(text));
|
|
}
|
|
if self.config.extract_technology {
|
|
entities.extend(extract_technologies(text));
|
|
}
|
|
// Project names (CamelCase / kebab-case)
|
|
entities.extend(extract_projects(text));
|
|
if self.config.extract_capitalized {
|
|
entities.extend(extract_capitalized(text));
|
|
}
|
|
// Custom patterns
|
|
for (pattern, etype, confidence) in &self.config.custom_patterns {
|
|
entities.extend(extract_literal(text, pattern, etype.clone(), *confidence));
|
|
}
|
|
|
|
// Filter by min_confidence
|
|
entities.retain(|e| e.confidence >= self.config.min_confidence);
|
|
|
|
// Sort by start offset, then deduplicate overlapping spans (keep higher confidence).
|
|
entities.sort_by(|a, b| {
|
|
a.start_offset.cmp(&b.start_offset).then(
|
|
b.confidence
|
|
.partial_cmp(&a.confidence)
|
|
.unwrap_or(std::cmp::Ordering::Equal),
|
|
)
|
|
});
|
|
dedup_overlapping(entities)
|
|
}
|
|
|
|
/// Extract and deduplicate entities from multiple text chunks.
|
|
pub fn extract_batch(&self, texts: &[&str]) -> Vec<ExtractedEntity> {
|
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
let mut result: Vec<ExtractedEntity> = Vec::new();
|
|
for text in texts {
|
|
for entity in self.extract(text) {
|
|
let key = format!("{}\x00{:?}", entity.text.to_lowercase(), entity.entity_type);
|
|
if seen.insert(key) {
|
|
result.push(entity);
|
|
}
|
|
}
|
|
}
|
|
result
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Private extraction helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Remove entities whose spans overlap with a higher-confidence entity.
|
|
fn dedup_overlapping(mut entities: Vec<ExtractedEntity>) -> Vec<ExtractedEntity> {
|
|
let mut result: Vec<ExtractedEntity> = Vec::new();
|
|
for entity in entities.drain(..) {
|
|
let overlaps = result.iter().any(|existing| {
|
|
existing.start_offset < entity.end_offset && entity.start_offset < existing.end_offset
|
|
});
|
|
if !overlaps {
|
|
result.push(entity);
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Extract technology mentions using the built-in word list.
|
|
fn extract_technologies(text: &str) -> Vec<ExtractedEntity> {
|
|
let mut results = Vec::new();
|
|
for tech in TECHNOLOGIES {
|
|
// Find all occurrences (case-sensitive for tech names).
|
|
let mut start = 0;
|
|
while let Some(pos) = text[start..].find(tech) {
|
|
let abs = start + pos;
|
|
let end = abs + tech.len();
|
|
// Verify word boundaries.
|
|
let before_ok = abs == 0
|
|
|| !text.as_bytes()[abs - 1].is_ascii_alphanumeric()
|
|
&& text.as_bytes()[abs - 1] != b'_';
|
|
let after_ok = end >= text.len()
|
|
|| !text.as_bytes()[end].is_ascii_alphanumeric() && text.as_bytes()[end] != b'_';
|
|
if before_ok && after_ok {
|
|
// Verify there is no hyphen immediately after the tech name
|
|
// (which would mean it is part of a longer compound identifier
|
|
// like "clawhdf5-agent"). The full compound will be captured by
|
|
// the kebab-case project extractor instead.
|
|
let not_hyphen_after = end >= text.len() || text.as_bytes()[end] != b'-';
|
|
if not_hyphen_after {
|
|
results.push(ExtractedEntity {
|
|
text: tech.to_string(),
|
|
entity_type: ExtractedEntityType::Technology,
|
|
start_offset: abs,
|
|
end_offset: end,
|
|
confidence: 0.9,
|
|
});
|
|
}
|
|
}
|
|
start = abs + 1;
|
|
}
|
|
}
|
|
results
|
|
}
|
|
|
|
/// Extract ISO and English date patterns.
|
|
fn extract_dates(text: &str) -> Vec<ExtractedEntity> {
|
|
let mut results = Vec::new();
|
|
let lower = text.to_lowercase();
|
|
|
|
// Multi-word relative dates first (longest first to avoid partial matches).
|
|
let mut sorted_rel: Vec<&str> = RELATIVE_DATES.to_vec();
|
|
sorted_rel.sort_by_key(|b| std::cmp::Reverse(b.len()));
|
|
for rel in sorted_rel {
|
|
let mut start = 0;
|
|
while let Some(pos) = lower[start..].find(rel) {
|
|
let abs = start + pos;
|
|
let end = abs + rel.len();
|
|
let before_ok = abs == 0 || !lower.as_bytes()[abs - 1].is_ascii_alphabetic();
|
|
let after_ok = end >= lower.len() || !lower.as_bytes()[end].is_ascii_alphabetic();
|
|
if before_ok && after_ok {
|
|
results.push(ExtractedEntity {
|
|
text: text[abs..end].to_string(),
|
|
entity_type: ExtractedEntityType::Date,
|
|
start_offset: abs,
|
|
end_offset: end,
|
|
confidence: 0.95,
|
|
});
|
|
}
|
|
start = abs + 1;
|
|
}
|
|
}
|
|
|
|
// ISO dates: YYYY-MM-DD or YYYY/MM/DD
|
|
let bytes = text.as_bytes();
|
|
let mut i = 0;
|
|
while i + 10 <= bytes.len() {
|
|
if bytes[i..i + 4].iter().all(|b| b.is_ascii_digit())
|
|
&& ISO_SEPS.contains(&(bytes[i + 4] as char))
|
|
&& bytes[i + 5..i + 7].iter().all(|b| b.is_ascii_digit())
|
|
&& bytes[i + 7] == bytes[i + 4]
|
|
&& bytes[i + 8..i + 10].iter().all(|b| b.is_ascii_digit())
|
|
{
|
|
let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
|
|
let end = i + 10;
|
|
let after_ok = end >= bytes.len() || !bytes[end].is_ascii_alphanumeric();
|
|
if before_ok && after_ok {
|
|
results.push(ExtractedEntity {
|
|
text: text[i..end].to_string(),
|
|
entity_type: ExtractedEntityType::Date,
|
|
start_offset: i,
|
|
end_offset: end,
|
|
confidence: 0.95,
|
|
});
|
|
i = end;
|
|
continue;
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
// English dates: "Month DD" or "Month DD, YYYY" or "DDth of Month"
|
|
for month in MONTHS {
|
|
let mut start = 0;
|
|
while let Some(pos) = lower[start..].find(month) {
|
|
let abs = start + pos;
|
|
let end_month = abs + month.len();
|
|
// Make sure it's a word boundary
|
|
let before_ok = abs == 0 || !lower.as_bytes()[abs - 1].is_ascii_alphabetic();
|
|
let after_ok =
|
|
end_month >= lower.len() || !lower.as_bytes()[end_month].is_ascii_alphabetic();
|
|
if before_ok && after_ok {
|
|
// Try "Month DD" or "Month DD, YYYY"
|
|
let rest = &text[end_month..];
|
|
let rest_trim = rest.trim_start();
|
|
let ws_len = rest.len() - rest_trim.len();
|
|
if let Some(day_len) = leading_digits_len(rest_trim) {
|
|
let mut end = end_month + ws_len + day_len;
|
|
// Optional ordinal suffix
|
|
let suf = &text[end..];
|
|
for ord in &["st", "nd", "rd", "th"] {
|
|
if suf.to_lowercase().starts_with(ord) {
|
|
end += ord.len();
|
|
break;
|
|
}
|
|
}
|
|
// Optional ", YYYY"
|
|
let rest2 = text[end..].trim_start();
|
|
let ws2 = text[end..].len() - rest2.len();
|
|
if let Some(after_comma) = rest2.strip_prefix(',') {
|
|
let rest3 = after_comma.trim_start();
|
|
if let Some(yr_len) = leading_digits_len(rest3)
|
|
&& yr_len == 4
|
|
{
|
|
end = end + ws2 + 1 + (after_comma.len() - rest3.len()) + yr_len;
|
|
}
|
|
}
|
|
results.push(ExtractedEntity {
|
|
text: text[abs..end].to_string(),
|
|
entity_type: ExtractedEntityType::Date,
|
|
start_offset: abs,
|
|
end_offset: end,
|
|
confidence: 0.95,
|
|
});
|
|
}
|
|
}
|
|
start = abs + 1;
|
|
}
|
|
}
|
|
|
|
results
|
|
}
|
|
|
|
fn leading_digits_len(s: &str) -> Option<usize> {
|
|
let n = s.bytes().take_while(|b| b.is_ascii_digit()).count();
|
|
if n > 0 { Some(n) } else { None }
|
|
}
|
|
|
|
/// Extract CamelCase or kebab-case project identifiers.
|
|
fn extract_projects(text: &str) -> Vec<ExtractedEntity> {
|
|
let mut results = Vec::new();
|
|
let bytes = text.as_bytes();
|
|
let len = bytes.len();
|
|
let mut i = 0;
|
|
|
|
// CamelCase: starts with uppercase, has at least one more uppercase-then-lower transition.
|
|
while i < len {
|
|
if bytes[i].is_ascii_uppercase() {
|
|
let start = i;
|
|
// Collect the whole identifier (letters, digits, underscore).
|
|
while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
|
|
i += 1;
|
|
}
|
|
let word = &text[start..i];
|
|
// Must contain at least one internal uppercase letter after the first char
|
|
// and be at least 4 chars long to avoid false positives.
|
|
if word.len() >= 4 && has_camel_hump(word) {
|
|
// Not just an all-caps acronym
|
|
let lower_count = word.bytes().filter(|b| b.is_ascii_lowercase()).count();
|
|
if lower_count >= 2 {
|
|
results.push(ExtractedEntity {
|
|
text: word.to_string(),
|
|
entity_type: ExtractedEntityType::Project,
|
|
start_offset: start,
|
|
end_offset: i,
|
|
confidence: 0.7,
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
|
|
// kebab-case: lowercase-word hyphen lowercase-word (at least 2 segments).
|
|
i = 0;
|
|
while i < len {
|
|
if bytes[i].is_ascii_lowercase() {
|
|
let start = i;
|
|
while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-') {
|
|
i += 1;
|
|
}
|
|
let candidate = &text[start..i];
|
|
// Must contain at least one hyphen and no spaces.
|
|
if candidate.contains('-') && candidate.len() >= 4 {
|
|
// Each segment must be lowercase alpha.
|
|
let segments: Vec<&str> = candidate.split('-').collect();
|
|
if segments.len() >= 2
|
|
&& segments
|
|
.iter()
|
|
.all(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric()))
|
|
{
|
|
results.push(ExtractedEntity {
|
|
text: candidate.to_string(),
|
|
entity_type: ExtractedEntityType::Project,
|
|
start_offset: start,
|
|
end_offset: i,
|
|
confidence: 0.7,
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
|
|
results
|
|
}
|
|
|
|
fn has_camel_hump(s: &str) -> bool {
|
|
let bytes = s.as_bytes();
|
|
// After the first char, look for an uppercase letter.
|
|
bytes[1..].iter().any(|b| b.is_ascii_uppercase())
|
|
}
|
|
|
|
/// Extract capitalized phrases (Person, Organization, Location).
|
|
fn extract_capitalized(text: &str) -> Vec<ExtractedEntity> {
|
|
let mut results = Vec::new();
|
|
// Split into sentences by '.', '!', '?'
|
|
// We want to skip the first word of each sentence to avoid false positives.
|
|
let sentence_starts = sentence_start_positions(text);
|
|
|
|
let words: Vec<(usize, &str)> = word_positions(text);
|
|
let n = words.len();
|
|
let mut i = 0;
|
|
|
|
while i < n {
|
|
let (pos, word) = words[i];
|
|
// Skip if at sentence start.
|
|
if sentence_starts.contains(&pos) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if is_capitalized(word) && !is_stop_word(word) {
|
|
// Collect a run of up to 3 capitalized words.
|
|
let mut run: Vec<(usize, &str)> = vec![(pos, word)];
|
|
let mut j = i + 1;
|
|
while j < n && run.len() < 3 {
|
|
let (p2, w2) = words[j];
|
|
if is_capitalized(w2) && !is_stop_word(w2) {
|
|
run.push((p2, w2));
|
|
j += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
// Determine entity type from the run.
|
|
let last_word = run.last().unwrap().1;
|
|
let entity_type = if ORG_SUFFIXES
|
|
.iter()
|
|
.any(|s| last_word.eq_ignore_ascii_case(s))
|
|
{
|
|
ExtractedEntityType::Organization
|
|
} else if LOCATION_SUFFIXES
|
|
.iter()
|
|
.any(|s| last_word.eq_ignore_ascii_case(s))
|
|
{
|
|
ExtractedEntityType::Location
|
|
} else if run.len() == 2 {
|
|
ExtractedEntityType::Person
|
|
} else {
|
|
ExtractedEntityType::Organization
|
|
};
|
|
|
|
let start = run.first().unwrap().0;
|
|
let last_start = run.last().unwrap().0;
|
|
let last_word_bytes = run.last().unwrap().1;
|
|
let end = last_start + last_word_bytes.len();
|
|
|
|
results.push(ExtractedEntity {
|
|
text: text[start..end].to_string(),
|
|
entity_type,
|
|
start_offset: start,
|
|
end_offset: end,
|
|
confidence: 0.6,
|
|
});
|
|
i = j;
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
|
|
results
|
|
}
|
|
|
|
/// Find the byte offsets of all sentence-starting word positions.
|
|
fn sentence_start_positions(text: &str) -> std::collections::HashSet<usize> {
|
|
let mut starts = std::collections::HashSet::new();
|
|
// The very first word is a sentence start.
|
|
let mut after_end = true;
|
|
for (i, ch) in text.char_indices() {
|
|
if after_end && ch.is_alphabetic() {
|
|
starts.insert(i);
|
|
after_end = false;
|
|
}
|
|
if matches!(ch, '.' | '!' | '?' | '\n') {
|
|
after_end = true;
|
|
}
|
|
}
|
|
starts
|
|
}
|
|
|
|
/// Return (byte_offset, word_str) pairs for all word tokens.
|
|
fn word_positions(text: &str) -> Vec<(usize, &str)> {
|
|
let mut result = Vec::new();
|
|
let mut i = 0;
|
|
let bytes = text.as_bytes();
|
|
let len = bytes.len();
|
|
while i < len {
|
|
if bytes[i].is_ascii_alphabetic() {
|
|
let start = i;
|
|
while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'\'') {
|
|
i += 1;
|
|
}
|
|
result.push((start, &text[start..i]));
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
fn is_capitalized(word: &str) -> bool {
|
|
word.chars()
|
|
.next()
|
|
.map(|c| c.is_uppercase())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn is_stop_word(word: &str) -> bool {
|
|
matches!(
|
|
word,
|
|
"The"
|
|
| "A"
|
|
| "An"
|
|
| "In"
|
|
| "On"
|
|
| "At"
|
|
| "To"
|
|
| "For"
|
|
| "Of"
|
|
| "And"
|
|
| "Or"
|
|
| "But"
|
|
| "Is"
|
|
| "Are"
|
|
| "Was"
|
|
| "Were"
|
|
| "It"
|
|
| "This"
|
|
| "That"
|
|
| "These"
|
|
| "Those"
|
|
| "He"
|
|
| "She"
|
|
| "They"
|
|
| "We"
|
|
| "I"
|
|
| "My"
|
|
| "His"
|
|
| "Her"
|
|
| "Its"
|
|
| "Our"
|
|
)
|
|
}
|
|
|
|
/// Extract all literal occurrences of `pattern` in `text`.
|
|
fn extract_literal(
|
|
text: &str,
|
|
pattern: &str,
|
|
entity_type: ExtractedEntityType,
|
|
confidence: f32,
|
|
) -> Vec<ExtractedEntity> {
|
|
let mut results = Vec::new();
|
|
let mut start = 0;
|
|
while let Some(pos) = text[start..].find(pattern) {
|
|
let abs = start + pos;
|
|
let end = abs + pattern.len();
|
|
results.push(ExtractedEntity {
|
|
text: pattern.to_string(),
|
|
entity_type: entity_type.clone(),
|
|
start_offset: abs,
|
|
end_offset: end,
|
|
confidence,
|
|
});
|
|
start = abs + 1;
|
|
}
|
|
results
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn default_extractor() -> EntityExtractor {
|
|
EntityExtractor::new(ExtractorConfig::default())
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Technology extraction
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_tech_rust() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("We use Rust for the backend.");
|
|
assert!(
|
|
entities
|
|
.iter()
|
|
.any(|x| x.text == "Rust" && x.entity_type == ExtractedEntityType::Technology)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tech_multiple() {
|
|
let e = default_extractor();
|
|
let text = "The stack uses Python, PostgreSQL, and Docker.";
|
|
let entities = e.extract(text);
|
|
let techs: Vec<&str> = entities
|
|
.iter()
|
|
.filter(|x| x.entity_type == ExtractedEntityType::Technology)
|
|
.map(|x| x.text.as_str())
|
|
.collect();
|
|
assert!(techs.contains(&"Python"), "missing Python");
|
|
assert!(techs.contains(&"PostgreSQL"), "missing PostgreSQL");
|
|
assert!(techs.contains(&"Docker"), "missing Docker");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tech_confidence() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("Using Redis for caching.");
|
|
let tech = entities
|
|
.iter()
|
|
.find(|x| x.text == "Redis")
|
|
.expect("Redis not found");
|
|
assert!((tech.confidence - 0.9).abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tech_word_boundary() {
|
|
let e = default_extractor();
|
|
// "Rust" inside "Rustic" should NOT match.
|
|
let entities = e.extract("The rustic cabin is beautiful.");
|
|
assert!(!entities.iter().any(|x| x.text == "Rust"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tech_disabled() {
|
|
let config = ExtractorConfig {
|
|
extract_technology: false,
|
|
..Default::default()
|
|
};
|
|
let e = EntityExtractor::new(config);
|
|
let entities = e.extract("We use Rust and Docker.");
|
|
assert!(
|
|
!entities
|
|
.iter()
|
|
.any(|x| x.entity_type == ExtractedEntityType::Technology)
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Date extraction
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_date_iso() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("Deployed on 2024-03-19 at noon.");
|
|
assert!(
|
|
entities
|
|
.iter()
|
|
.any(|x| x.text == "2024-03-19" && x.entity_type == ExtractedEntityType::Date)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_date_iso_slash() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("Report date: 2024/03/19.");
|
|
assert!(
|
|
entities
|
|
.iter()
|
|
.any(|x| x.text == "2024/03/19" && x.entity_type == ExtractedEntityType::Date)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_date_relative_yesterday() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("I saw it yesterday at the office.");
|
|
assert!(
|
|
entities.iter().any(|x| x.text.to_lowercase() == "yesterday"
|
|
&& x.entity_type == ExtractedEntityType::Date)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_date_relative_last_week() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("We merged the PR last week.");
|
|
assert!(
|
|
entities.iter().any(|x| x.text.to_lowercase() == "last week"
|
|
&& x.entity_type == ExtractedEntityType::Date)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_date_english_month_day() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("Meeting on March 19 to discuss the roadmap.");
|
|
assert!(
|
|
entities
|
|
.iter()
|
|
.any(|x| x.text.starts_with("March") && x.entity_type == ExtractedEntityType::Date)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_date_disabled() {
|
|
let config = ExtractorConfig {
|
|
extract_dates: false,
|
|
..Default::default()
|
|
};
|
|
let e = EntityExtractor::new(config);
|
|
let entities = e.extract("Released on 2024-03-19.");
|
|
assert!(
|
|
!entities
|
|
.iter()
|
|
.any(|x| x.entity_type == ExtractedEntityType::Date)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_date_confidence() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("Deadline: 2025-12-31.");
|
|
let d = entities
|
|
.iter()
|
|
.find(|x| x.entity_type == ExtractedEntityType::Date)
|
|
.expect("no date");
|
|
assert!(d.confidence >= 0.9);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Project extraction
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_project_camelcase() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("ClawBrainHub is the core component.");
|
|
assert!(
|
|
entities
|
|
.iter()
|
|
.any(|x| x.text == "ClawBrainHub" && x.entity_type == ExtractedEntityType::Project)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_project_kebab() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("See the clawhdf5-agent crate for details.");
|
|
assert!(
|
|
entities.iter().any(
|
|
|x| x.text == "clawhdf5-agent" && x.entity_type == ExtractedEntityType::Project
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_project_kebab_multi() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("my-cool-project is production-ready.");
|
|
assert!(
|
|
entities
|
|
.iter()
|
|
.any(|x| x.text == "my-cool-project"
|
|
&& x.entity_type == ExtractedEntityType::Project)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_project_confidence() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("ClawBrainHub handles memory.");
|
|
let proj = entities
|
|
.iter()
|
|
.find(|x| x.entity_type == ExtractedEntityType::Project)
|
|
.expect("no project");
|
|
assert!((proj.confidence - 0.7).abs() < 0.01);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Capitalized phrase extraction
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_capitalized_person() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("I spoke with John Smith about the project.");
|
|
assert!(
|
|
entities
|
|
.iter()
|
|
.any(|x| x.text == "John Smith" && x.entity_type == ExtractedEntityType::Person)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_capitalized_org() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("We partnered with Red Hat Systems for support.");
|
|
assert!(
|
|
entities
|
|
.iter()
|
|
.any(|x| x.entity_type == ExtractedEntityType::Organization)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_capitalized_sentence_start_skipped() {
|
|
let e = default_extractor();
|
|
// "The" at sentence start should not be extracted.
|
|
let entities = e.extract("The meeting was held yesterday.");
|
|
assert!(!entities.iter().any(|x| x.text == "The"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_all_lowercase_no_cap_entities() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("everything here is lowercase and has no entities.");
|
|
assert!(
|
|
!entities
|
|
.iter()
|
|
.any(|x| x.entity_type == ExtractedEntityType::Person)
|
|
);
|
|
assert!(
|
|
!entities
|
|
.iter()
|
|
.any(|x| x.entity_type == ExtractedEntityType::Organization)
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Edge cases
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_empty_text() {
|
|
let e = default_extractor();
|
|
let entities = e.extract("");
|
|
assert!(entities.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_confidence_filter() {
|
|
let config = ExtractorConfig {
|
|
min_confidence: 0.95,
|
|
..Default::default()
|
|
};
|
|
let e = EntityExtractor::new(config);
|
|
// Only dates (0.95) and techs (0.9) should survive; 0.9 < 0.95 filters techs.
|
|
let entities = e.extract("We use Rust since 2024-01-01.");
|
|
assert!(
|
|
!entities
|
|
.iter()
|
|
.any(|x| x.entity_type == ExtractedEntityType::Technology)
|
|
);
|
|
assert!(
|
|
entities
|
|
.iter()
|
|
.any(|x| x.entity_type == ExtractedEntityType::Date)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_batch_dedup() {
|
|
let e = default_extractor();
|
|
let texts = ["We use Rust.", "Rust is fast.", "Also Rust for safety."];
|
|
let entities = e.extract_batch(&texts);
|
|
let rust_count = entities.iter().filter(|x| x.text == "Rust").count();
|
|
assert_eq!(rust_count, 1, "Rust should appear exactly once after dedup");
|
|
}
|
|
|
|
#[test]
|
|
fn test_batch_multiple_types() {
|
|
let e = default_extractor();
|
|
let texts = ["Deploy with Docker.", "We merged last week."];
|
|
let entities = e.extract_batch(&texts);
|
|
assert!(
|
|
entities
|
|
.iter()
|
|
.any(|x| x.entity_type == ExtractedEntityType::Technology)
|
|
);
|
|
assert!(
|
|
entities
|
|
.iter()
|
|
.any(|x| x.entity_type == ExtractedEntityType::Date)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_custom_pattern() {
|
|
let mut config = ExtractorConfig::default();
|
|
config.custom_patterns.push((
|
|
"CRITICAL".to_string(),
|
|
ExtractedEntityType::Custom("Alert".to_string()),
|
|
0.99,
|
|
));
|
|
let e = EntityExtractor::new(config);
|
|
let entities = e.extract("CRITICAL failure detected.");
|
|
assert!(
|
|
entities
|
|
.iter()
|
|
.any(|x| x.entity_type == ExtractedEntityType::Custom("Alert".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_offsets_correct() {
|
|
let e = default_extractor();
|
|
let text = "Released 2024-06-15 for testing.";
|
|
let entities = e.extract(text);
|
|
let d = entities
|
|
.iter()
|
|
.find(|x| x.entity_type == ExtractedEntityType::Date)
|
|
.expect("no date");
|
|
assert_eq!(&text[d.start_offset..d.end_offset], d.text);
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_false_positive_lowercased_tech() {
|
|
let e = default_extractor();
|
|
// "rust" lowercase is NOT in the TECHNOLOGIES list (case-sensitive).
|
|
let entities = e.extract("The rust on the pipes was visible.");
|
|
assert!(
|
|
!entities
|
|
.iter()
|
|
.any(|x| x.text == "rust" && x.entity_type == ExtractedEntityType::Technology)
|
|
);
|
|
}
|
|
}
|