use exocortex_kernel::{EntityId, Memory};
use lasso::{Rodeo, Spur};
use once_cell::sync::Lazy;
use regex::RegexSet;
use smol_str::SmolStr;
pub const ENTITY_TYPE_COUNT: usize = 12;
fn pattern_table() -> Vec<(&'static str, Vec<&'static str>)> {
vec![
(
"File",
vec![r"\b[\w\-/\.]+\.(rs|toml|py|ts|js|go|java|c|cpp|h|md)\b"],
),
(
"Function",
vec![
r"\b[a-z_][a-z0-9_]*\(\s*\)", r"\bfn\s+([a-z_][a-z0-9_]*)",
],
),
(
"Class",
vec![r"\b(?:struct|enum|trait|class)\s+([A-Z][A-Za-z0-9_]*)"],
),
(
"Error",
vec![r"\b[A-Z][A-Za-z0-9]*Error\b", r"\bpanic![^\]]*\]"],
),
(
"Technology",
vec![
r"\b(rust|tokio|axum|serde|falkordb|redis|postgres|sqlite|python|typescript|kubernetes|docker)\b",
],
),
(
"Concept",
vec![
r"\b[A-Z][a-z]+(?:[ -][A-Z][a-z]+)+\b", ],
),
(
"Person",
vec![
r"@[a-z][a-z0-9_]{2,}", ],
),
(
"Project",
vec![r"\b[a-z0-9]+-[a-z0-9]+(?:-[a-z0-9]+)*-service\b"],
),
(
"Command",
vec![r"\b(?:cargo|npm|pnpm|git|docker|kubectl|make|brew)\s+[a-z\-]+"],
),
(
"Package",
vec![
r"\b[a-z0-9_\-]+@[0-9]+\.[0-9]+(?:\.[0-9]+)?\b", ],
),
("Url", vec![r"https?://[^\s]+"]),
(
"Variable",
vec![
r"\b[A-Z_][A-Z0-9_]{3,}\b", ],
),
]
}
static TABLE: Lazy<Vec<(&'static str, RegexSet)>> = Lazy::new(|| {
pattern_table()
.into_iter()
.map(|(name, pats)| {
(
name,
RegexSet::new(pats).expect("extraction patterns compile"),
)
})
.collect()
});
#[derive(Clone)]
pub struct EntityExtractor {
org_id: String,
#[allow(dead_code)] interner: std::sync::Arc<std::sync::Mutex<Rodeo>>,
}
impl EntityExtractor {
pub fn new(org_id: &str) -> Self {
Self {
org_id: org_id.to_string(),
interner: std::sync::Arc::new(std::sync::Mutex::new(Rodeo::new())),
}
}
pub fn extract(&self, content: &str, tags: &[SmolStr]) -> Vec<(u8, String, f32)> {
let mut out: Vec<(u8, String, f32)> = Vec::new();
let mut seen = std::collections::HashSet::new();
let text = format!(
"{content} {}",
tags.iter()
.map(|t| t.as_str())
.collect::<Vec<_>>()
.join(" ")
);
for (type_idx, (type_name, set)) in TABLE.iter().enumerate() {
let matches = set.matches(&text);
let mut hits: Vec<String> = Vec::new();
for mi in matches.iter() {
let pat = pattern_of(type_idx, mi);
if let Some(m) = pat.find(&text) {
let raw = m.as_str().trim();
hits.push(raw.to_string());
}
}
let ambiguous = hits.len() > 1;
for h in hits {
let canonical = canonicalize(type_name, &h);
if seen.insert((type_idx as u8, canonical.clone())) {
out.push((
type_idx as u8,
canonical,
if ambiguous { 0.6 } else { 0.95 },
));
}
}
}
out.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
out.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
out
}
pub fn entity_ids(&self, content: &str, tags: &[SmolStr]) -> Vec<EntityId> {
let mut ids: Vec<EntityId> = self
.extract(content, tags)
.into_iter()
.map(|(t, name, _)| EntityId::from_parts(&self.org_id, t, &name))
.collect();
ids.sort();
ids.dedup();
ids
}
}
fn pattern_of(type_idx: usize, pattern_idx: usize) -> &'static regex::Regex {
static PER_PATTERN: Lazy<Vec<Vec<regex::Regex>>> = Lazy::new(|| {
pattern_table()
.into_iter()
.map(|(_, pats)| {
pats.into_iter()
.map(|p| regex::Regex::new(p).expect("pattern compiles"))
.collect()
})
.collect()
});
&PER_PATTERN[type_idx][pattern_idx]
}
fn canonicalize(type_name: &str, raw: &str) -> String {
match type_name {
"Technology" | "Concept" | "Project" => raw.to_lowercase(),
"Url" => raw.trim_end_matches(['.', ',']).to_string(),
_ => raw.to_string(),
}
}
pub fn table_is_complete() -> bool {
TABLE.len() == ENTITY_TYPE_COUNT && TABLE.iter().all(|(_, set)| !set.patterns().is_empty())
}
pub fn intern_name(interner: &mut Rodeo, name: &str) -> Spur {
interner.get_or_intern(name)
}
pub fn attach_entities(m: &mut Memory, extractor: &EntityExtractor) {
let ids = extractor.entity_ids(&m.content, &m.tags);
m.context.entities = ids.into_iter().collect();
}