use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EdgeType {
Mentions,
Attended,
WorksAt,
InvestedIn,
Founded,
Advises,
}
impl EdgeType {
pub fn as_str(self) -> &'static str {
match self {
EdgeType::Mentions => "mentions",
EdgeType::Attended => "attended",
EdgeType::WorksAt => "works_at",
EdgeType::InvestedIn => "invested_in",
EdgeType::Founded => "founded",
EdgeType::Advises => "advises",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Edge {
pub source: String,
pub target: String,
#[serde(rename = "type")]
pub edge_type: EdgeType,
pub evidence: Option<String>,
}
static MD_LINK: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\[([^\]]+)\]\(([^)#\s]+)\)").unwrap());
static BARE_SLUG: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?i)(?:^|[^/\w])@?((?:people|companies|deals|projects|investors|firms)/[a-z0-9][a-z0-9-]*)",
)
.unwrap()
});
static CODE_FENCE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?s)```.*?```").unwrap());
static INLINE_CODE: Lazy<Regex> = Lazy::new(|| Regex::new(r"`[^`\n]+`").unwrap());
static ROLE_PATTERNS: Lazy<Vec<(Regex, EdgeType)>> = Lazy::new(|| {
vec![
(Regex::new(r"(?i)\b(?:co-?)?founded\s+([^.\n]+?)(?:[.\n]|$)").unwrap(), EdgeType::Founded),
(Regex::new(r"(?i)\bfounder\s+(?:and\s+\w+\s+)?of\s+([^.\n]+?)(?:[.\n]|$)").unwrap(), EdgeType::Founded),
(Regex::new(r"(?i)\binvested\s+in\s+([^.\n]+?)(?:[.\n]|$)").unwrap(), EdgeType::InvestedIn),
(Regex::new(r"(?i)\bled\s+([^.\n]+?)['’]s\s+(?:seed|series|round)").unwrap(), EdgeType::InvestedIn),
(Regex::new(r"(?i)\bwrote\s+(?:a\s+)?check\s+into\s+([^.\n]+?)(?:[.\n]|$)").unwrap(), EdgeType::InvestedIn),
(Regex::new(r"(?i)\badvises\s+([^.\n]+?)(?:[.\n]|$)").unwrap(), EdgeType::Advises),
(Regex::new(r"(?i)\badvisor\s+(?:to|at|for)\s+([^.\n]+?)(?:[.\n]|$)").unwrap(), EdgeType::Advises),
(Regex::new(r"(?i)\b(?:CEO|CTO|COO|CFO|VP|head\s+of\s+\w+|director)\s+of\s+([^.\n]+?)(?:[.\n]|$)").unwrap(), EdgeType::WorksAt),
(Regex::new(r"\bjoined\s+([A-Z][^\s.]*(?:\s+[A-Z][^\s.]*)*)(?:\s+(?:as|in)\b|[\s.,;:!?\n]|$)").unwrap(), EdgeType::WorksAt),
(Regex::new(r"(?i)\bworks\s+at\s+([^.\n]+?)(?:[.\n]|$)").unwrap(), EdgeType::WorksAt),
]
});
pub fn slugify(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let lower = s.to_lowercase().replace('&', " and ");
let mut last_dash = true;
for ch in lower.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch);
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
let trimmed = out.trim_matches('-');
let take = trimmed.chars().take(80).collect::<String>();
take
}
fn strip_code(md: &str) -> String {
let no_fences = CODE_FENCE.replace_all(md, "");
INLINE_CODE.replace_all(&no_fences, "").into_owned()
}
fn infer_category(t: EdgeType) -> &'static str {
match t {
EdgeType::Founded | EdgeType::InvestedIn | EdgeType::WorksAt => "companies",
EdgeType::Advises => "people",
_ => "entities",
}
}
pub fn extract_edges(slug: &str, content: &str, page_type: Option<&str>) -> Vec<Edge> {
let cleaned = strip_code(content);
let mut seen: std::collections::HashMap<(String, EdgeType), Edge> =
std::collections::HashMap::new();
let meeting = page_type == Some("meeting");
for cap in MD_LINK.captures_iter(&cleaned) {
let target = cap.get(2).map(|m| m.as_str().trim()).unwrap_or("");
if target.starts_with("http") || target.starts_with('/') || !target.contains('/') {
continue;
}
let ty = if meeting { EdgeType::Attended } else { EdgeType::Mentions };
let edge = Edge {
source: slug.to_string(),
target: target.to_string(),
edge_type: ty,
evidence: Some(cap.get(0).unwrap().as_str().to_string()),
};
seen.entry((edge.target.clone(), edge.edge_type)).or_insert(edge);
}
for cap in BARE_SLUG.captures_iter(&cleaned) {
let target = cap.get(1).unwrap().as_str().to_lowercase();
let ty = if meeting { EdgeType::Attended } else { EdgeType::Mentions };
let edge = Edge {
source: slug.to_string(),
target,
edge_type: ty,
evidence: Some(cap.get(0).unwrap().as_str().to_string()),
};
seen.entry((edge.target.clone(), edge.edge_type)).or_insert(edge);
}
for (pat, etype) in ROLE_PATTERNS.iter() {
if let Some(cap) = pat.captures(&cleaned) {
let raw = cap.get(1).unwrap().as_str().trim().trim_end_matches(['.', ',', ';', ':', '!', '?']);
let target_slug = format!("{}/{}", infer_category(*etype), slugify(raw));
if target_slug.ends_with('/') {
continue;
}
let edge = Edge {
source: slug.to_string(),
target: target_slug.clone(),
edge_type: *etype,
evidence: Some(cap.get(0).unwrap().as_str().to_string()),
};
seen.entry((target_slug, *etype)).or_insert(edge);
}
}
seen.into_values().collect()
}
pub fn reconcile(prior: &[Edge], next: &[Edge]) -> (Vec<Edge>, Vec<Edge>) {
use std::collections::HashSet;
let key = |e: &Edge| (e.source.clone(), e.target.clone(), e.edge_type);
let prior_keys: HashSet<_> = prior.iter().map(key).collect();
let next_keys: HashSet<_> = next.iter().map(key).collect();
let add: Vec<_> = next.iter().filter(|e| !prior_keys.contains(&key(e))).cloned().collect();
let remove: Vec<_> = prior.iter().filter(|e| !next_keys.contains(&key(e))).cloned().collect();
(add, remove)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_lowercases_dashes_ascii() {
assert_eq!(slugify("Acme AI Inc."), "acme-ai-inc");
assert_eq!(slugify("Slack & Discord"), "slack-and-discord");
}
#[test]
fn extracts_founded_invested_works_at() {
let edges = extract_edges(
"people/alice",
"Alice is the CEO of Acme. She founded Beta Co. She invested in Foobar.",
Some("person"),
);
let targets: std::collections::HashSet<_> =
edges.iter().map(|e| (e.target.as_str(), e.edge_type)).collect();
assert!(targets.contains(&("companies/acme", EdgeType::WorksAt)));
assert!(targets.contains(&("companies/beta-co", EdgeType::Founded)));
assert!(targets.contains(&("companies/foobar", EdgeType::InvestedIn)));
}
#[test]
fn meeting_pages_emit_attended() {
let edges = extract_edges(
"meetings/m1",
"Met with [Bob](people/bob) and [Carol](people/carol).",
Some("meeting"),
);
let types: std::collections::HashSet<_> = edges.iter().map(|e| e.edge_type).collect();
assert!(types.contains(&EdgeType::Attended));
assert!(!types.contains(&EdgeType::Mentions));
}
#[test]
fn strips_code_fences() {
let edges = extract_edges(
"concepts/snippet",
"Normal: [link](people/real). Code:\n```\nfake = [x](people/fake)\n```\n",
None,
);
let targets: std::collections::HashSet<_> = edges.iter().map(|e| e.target.as_str()).collect();
assert!(targets.contains("people/real"));
assert!(!targets.contains("people/fake"));
}
#[test]
fn reconcile_add_remove() {
let prior = vec![
Edge { source: "a".into(), target: "x".into(), edge_type: EdgeType::Mentions, evidence: None },
Edge { source: "a".into(), target: "y".into(), edge_type: EdgeType::Mentions, evidence: None },
];
let next = vec![
Edge { source: "a".into(), target: "y".into(), edge_type: EdgeType::Mentions, evidence: None },
Edge { source: "a".into(), target: "z".into(), edge_type: EdgeType::Mentions, evidence: None },
];
let (add, remove) = reconcile(&prior, &next);
assert_eq!(add.len(), 1);
assert_eq!(add[0].target, "z");
assert_eq!(remove.len(), 1);
assert_eq!(remove[0].target, "x");
}
}