use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone)]
pub struct SocialEntity {
pub entity_type: EntityType,
pub label: String,
pub role: Option<String>,
pub source: String,
pub origin: EntityOrigin,
pub confidence: f32,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum EntityType {
Person,
Organisation,
Capability,
}
#[derive(Debug, Clone, PartialEq)]
pub enum EntityOrigin {
Symbolic,
Neural,
}
#[derive(Debug)]
pub struct SocialResult {
pub entities: Vec<SocialEntity>,
pub documents_scanned: usize,
pub symbolic_count: usize,
pub neural_count: usize,
}
#[derive(Debug, Clone)]
pub struct SocialConfig {
pub base_iri: String,
pub ner_threshold: f32,
}
impl Default for SocialConfig {
fn default() -> Self {
Self {
base_iri: "urn:topo:social:".to_string(),
ner_threshold: 0.70,
}
}
}
fn extract_persons_regex(content: &str, source: &str) -> Vec<SocialEntity> {
use regex::Regex;
let mut results = Vec::new();
let mut seen = HashSet::new();
let re1a = Regex::new(
r"(?:^|\s)([A-Z][a-zéèêëàâäùûü]+ [A-Z][a-zéèêëàâäùûü]+)\s*[-–—,]\s*(CEO|CTO|CIO|CISO|CFO|COO|DPO|RSSI|DSI|Founder|Director|Lead|Manager|Architect|Engineer|Developer|Consultant|Partner|Directeur|Responsable|Ingénieur|Fondateur)"
).unwrap();
let re1b = Regex::new(
r"(?:^|\s)([A-Z][a-zéèêëàâäùûü]+ [A-Z]{2,}[A-Z]*)\s*[-–—,]\s*(CEO|CTO|CIO|CISO|CFO|COO|DPO|RSSI|DSI|Founder|Director|Lead|Manager|Architect|Engineer|Developer|Consultant|Partner|Directeur|Responsable|Ingénieur|Fondateur)"
).unwrap();
let re1c = Regex::new(
r"(?:^[#*\s]*|—\s*)([A-Z][a-zéèêëàâäùûü]+ [A-Z][A-ZÉÈÊËÀÂÄÙÛÜa-zéèêëàâäùûü]+)\s*[,]\s*(CEO|CTO|CIO|CISO|CFO|COO|DPO|RSSI|DSI|Founder|Fondateur|Lead\s*\w+|Architecte?\s*\w*)"
).unwrap();
for re in [&re1a, &re1b, &re1c] {
for cap in re.captures_iter(content) {
let name = cap[1].trim().to_string();
let role = cap[2].trim().to_string();
let name_normalized = normalize_person_name(&name);
if name_normalized.len() > 2 && seen.insert(name_normalized.to_lowercase()) {
results.push(SocialEntity {
entity_type: EntityType::Person,
label: name_normalized,
role: Some(role),
source: source.to_string(),
origin: EntityOrigin::Symbolic,
confidence: 1.0,
});
}
}
}
let re2 = Regex::new(
r"(?:^|\s)([A-Z][a-zéèêëàâäùûü]+)\s*\(([^)]*(?:CEO|CTO|CIO|CISO|CFO|Founder|Architect|Lead|Developer|Directeur|Responsable)[^)]*)\)"
).unwrap();
let not_persons: HashSet<&str> = [
"Scanner", "Module", "Pipeline", "Système", "Plateforme", "Solution",
"Document", "Projet", "Analyse", "Note", "Support", "Version",
].iter().copied().collect();
for cap in re2.captures_iter(content) {
let name = cap[1].trim().to_string();
let role = cap[2].trim().to_string();
if name.len() > 2 && !not_persons.contains(name.as_str()) && seen.insert(name.to_lowercase()) {
results.push(SocialEntity {
entity_type: EntityType::Person,
label: name,
role: Some(role),
source: source.to_string(),
origin: EntityOrigin::Symbolic,
confidence: 1.0,
});
}
}
let re3 = Regex::new(
r"(?m)^(?:\s*>?\s*)?([A-Z][a-zéèêëàâäùûü]+)\s+(?:est|mon|notre|le|la)\s+(\w+(?:\s+\w+)?)\s"
).unwrap();
let role_words: HashSet<&str> = [
"architecte", "développeur", "ingénieur", "directeur", "responsable",
"lead", "consultant", "fondateur", "CEO", "CTO",
].iter().copied().collect();
for cap in re3.captures_iter(content) {
let name = cap[1].trim().to_string();
let context = cap[2].trim().to_lowercase();
if role_words.iter().any(|rw| context.contains(rw)) && !not_persons.contains(name.as_str()) && seen.insert(name.to_lowercase()) {
results.push(SocialEntity {
entity_type: EntityType::Person,
label: name,
role: Some(context),
source: source.to_string(),
origin: EntityOrigin::Symbolic,
confidence: 0.8,
});
}
}
results
}
fn normalize_person_name(name: &str) -> String {
name.split_whitespace()
.map(|word| {
if word.len() > 1 && word.chars().all(|c| c.is_uppercase() || !c.is_alphabetic()) {
let mut chars = word.chars();
let first: String = chars.next().into_iter().collect();
let rest: String = chars.collect::<String>().to_lowercase();
format!("{}{}", first, rest)
} else {
word.to_string()
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn extract_orgs_regex(content: &str, source: &str) -> Vec<SocialEntity> {
use regex::Regex;
let re = Regex::new(
r"\b(ANSSI|CLUSIF|AFNOR|ENISA|BSI|NIST|ISO|IEEE|W3C|OWASP|MITRE|Gartner|Forrester|Deloitte|PwC|EY|KPMG|Accenture|Thales|Atos|Capgemini|OVH|AWS|Azure|GCP|Mistral|OpenAI|Anthropic|HuggingFace)\b"
).unwrap();
let mut seen = HashSet::new();
let mut results = Vec::new();
for cap in re.captures_iter(content) {
let org = cap[1].trim().to_string();
if seen.insert(org.clone()) {
results.push(SocialEntity {
entity_type: EntityType::Organisation,
label: org,
role: None,
source: source.to_string(),
origin: EntityOrigin::Symbolic,
confidence: 1.0,
});
}
}
results
}
fn extract_capabilities_regex(content: &str, source: &str) -> Vec<SocialEntity> {
use regex::Regex;
let mut seen = HashSet::new();
let mut results = Vec::new();
let re_explicit = Regex::new(
r"(?i)(?:module|capability|feature|service|capacité|fonctionnalité)\s*[:]\s*([^\n.]{5,80})"
).unwrap();
for cap in re_explicit.captures_iter(content) {
let desc = cap[1].trim().to_string();
if desc.len() > 5 && seen.insert(desc.to_lowercase()) {
results.push(SocialEntity {
entity_type: EntityType::Capability,
label: desc,
role: None,
source: source.to_string(),
origin: EntityOrigin::Symbolic,
confidence: 1.0,
});
}
}
let re_natural = Regex::new(
r"(?i)\b(?:un|une|le|la|les|du|des|notre|leur|son|sa)\s+(?:[a-zéèêëàâäùûüôïî]+\s+)?(?:scanner|plateforme|module|moteur|pipeline|outil|système|solution|framework|connecteur|intégration|API|LLM|modèle|algorithme|brique|composant)\s+(?:[a-zéèêëàâäùûüôïîA-Z0-9/''\-()]+(?:\s+[a-zéèêëàâäùûüôïîA-Z0-9/''\-()]+){0,6})"
).unwrap();
for m in re_natural.find_iter(content) {
let full_match = m.as_str().trim().to_string();
if full_match.ends_with('\'') || full_match.ends_with('\u{2019}') || full_match.ends_with(" d") || full_match.ends_with(" l") || full_match.ends_with(" qu") {
continue;
}
if full_match.len() < 15 {
continue;
}
if full_match.contains('\n') {
continue;
}
if full_match.contains('?') || full_match.contains("tient-il") || full_match.contains("comment") {
continue;
}
if seen.insert(full_match.to_lowercase()) {
results.push(SocialEntity {
entity_type: EntityType::Capability,
label: full_match,
role: None,
source: source.to_string(),
origin: EntityOrigin::Symbolic,
confidence: 0.5,
});
}
}
results
}
#[cfg(feature = "ner")]
fn edgy_candidates() -> Vec<crate::ner::Candidate> {
vec![
crate::ner::Candidate { label: "person individual team member stakeholder employee".into(), iri: "edgy:Person".into() },
crate::ner::Candidate { label: "organization company institution agency partner vendor".into(), iri: "edgy:Organisation".into() },
crate::ner::Candidate { label: "capability service feature module function system".into(), iri: "edgy:Capability".into() },
crate::ner::Candidate { label: "product tool platform software application".into(), iri: "edgy:Product".into() },
]
}
#[cfg(feature = "ner")]
fn extract_via_ner(
content: &str,
source: &str,
engine: &crate::ner::NerEngine,
threshold: f32,
) -> Vec<SocialEntity> {
let candidates = edgy_candidates();
let mut results = Vec::new();
let sentences: Vec<&str> = content
.split(|c: char| c == '.' || c == '\n')
.map(|s| s.trim())
.filter(|s| s.len() > 20)
.collect();
for sentence in sentences {
let ner_results = match engine.zero_shot_ner(sentence, &candidates, threshold) {
Ok(r) => r,
Err(_) => continue,
};
if ner_results.is_empty() {
continue;
}
let top = &ner_results[0];
let entity_type = match top.iri.as_str() {
"edgy:Person" => EntityType::Person,
"edgy:Organisation" => EntityType::Organisation,
"edgy:Capability" => EntityType::Capability,
_ => continue,
};
let label = extract_label_from_sentence(sentence, &entity_type);
if let Some(label) = label {
results.push(SocialEntity {
entity_type,
label,
role: None,
source: source.to_string(),
origin: EntityOrigin::Neural,
confidence: top.similarity,
});
}
}
results
}
#[cfg(feature = "ner")]
fn extract_label_from_sentence(sentence: &str, entity_type: &EntityType) -> Option<String> {
use regex::Regex;
match entity_type {
EntityType::Person => {
let re = Regex::new(r"([A-Z][a-zéèêëàâäùûü]+ [A-Z][a-zéèêëàâäùûü]+)").unwrap();
re.find(sentence).map(|m| m.as_str().to_string())
}
EntityType::Organisation => {
let re = Regex::new(r"\b([A-Z]{2,}(?:\s+[A-Z]{2,})*)\b").unwrap();
re.find(sentence).map(|m| m.as_str().to_string())
}
EntityType::Capability => {
let trimmed = sentence.trim();
let end = trimmed.char_indices()
.take_while(|(i, _)| *i < 80)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(trimmed.len());
Some(trimmed[..end].to_string())
}
}
}
pub fn extract_social(
dir: &str,
#[cfg(feature = "ner")] ner_engine: Option<&crate::ner::NerEngine>,
config: &SocialConfig,
) -> Result<SocialResult, Box<dyn std::error::Error>> {
let mut all_entities = Vec::new();
let mut documents_scanned = 0usize;
let md_files = find_md_files(dir)?;
for md_path in &md_files {
let content = std::fs::read_to_string(md_path)?;
let source = std::path::Path::new(md_path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
documents_scanned += 1;
all_entities.extend(extract_persons_regex(&content, &source));
all_entities.extend(extract_orgs_regex(&content, &source));
all_entities.extend(extract_capabilities_regex(&content, &source));
#[cfg(feature = "ner")]
if let Some(engine) = ner_engine {
let neural = extract_via_ner(&content, &source, engine, config.ner_threshold);
all_entities.extend(neural);
}
}
let (merged, symbolic_count, neural_count) = merge_social_entities(all_entities);
Ok(SocialResult {
entities: merged,
documents_scanned,
symbolic_count,
neural_count,
})
}
fn find_md_files(dir: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut files = Vec::new();
fn walk(dir: &std::path::Path, files: &mut Vec<String>) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') {
continue;
}
let path = entry.path();
if path.is_dir() {
walk(&path, files)?;
} else if path.extension().map_or(false, |e| e == "md") {
files.push(path.to_string_lossy().to_string());
}
}
Ok(())
}
walk(std::path::Path::new(dir), &mut files)?;
Ok(files)
}
fn merge_social_entities(
entities: Vec<SocialEntity>,
) -> (Vec<SocialEntity>, usize, usize) {
let mut seen: HashMap<(String, EntityType), SocialEntity> = HashMap::new();
let mut symbolic_count = 0usize;
let mut neural_count = 0usize;
for entity in entities {
let key = (entity.label.clone(), entity.entity_type.clone());
match entity.origin {
EntityOrigin::Symbolic => symbolic_count += 1,
EntityOrigin::Neural => neural_count += 1,
}
if let Some(existing) = seen.get(&key) {
if existing.origin == EntityOrigin::Neural && entity.origin == EntityOrigin::Symbolic {
seen.insert(key, entity);
}
} else {
seen.insert(key, entity);
}
}
(seen.into_values().collect(), symbolic_count, neural_count)
}
pub fn social_to_turtle(result: &SocialResult, config: &SocialConfig) -> String {
let mut lines = vec![
"@prefix edgy: <https://edgy.is/schema/0.95#> .".to_string(),
"@prefix prov: <http://www.w3.org/ns/prov#> .".to_string(),
"@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .".to_string(),
"@prefix re: <https://omyn.ai/schema/retroeng#> .".to_string(),
format!("@prefix tp: <{}> .", config.base_iri),
String::new(),
];
let sources: HashSet<&str> = result.entities.iter().map(|e| e.source.as_str()).collect();
for source in &sources {
let local = sanitize_local(source);
lines.push(format!("tp:doc-{} a prov:Entity ;", local));
lines.push(format!(" rdfs:label \"{}\"@en .", source));
lines.push(String::new());
}
for entity in &result.entities {
let (prefix, rdf_type) = match entity.entity_type {
EntityType::Person => ("person", "edgy:Person"),
EntityType::Organisation => ("org", "edgy:Organisation"),
EntityType::Capability => ("cap", "edgy:Capability"),
};
let local = sanitize_local(&entity.label);
let local = {
let end = local.char_indices()
.take_while(|(i, _)| *i < 60)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(local.len());
&local[..end]
};
let escaped_label = entity.label.replace('"', "\\\"").replace('\n', " ").replace('\r', "");
let source_local = sanitize_local(&entity.source);
lines.push(format!("tp:{}-{} a {} ;", prefix, local, rdf_type));
lines.push(format!(" rdfs:label \"{}\"@en ;", escaped_label));
if let Some(ref role) = entity.role {
lines.push(format!(" edgy:role \"{}\"@en ;", role));
}
lines.push(format!(" re:extractionConfidence {:.2} ;", entity.confidence));
let origin_label = match entity.origin {
EntityOrigin::Symbolic => "symbolic",
EntityOrigin::Neural => "neural",
};
lines.push(format!(" re:extractionOrigin \"{}\" ;", origin_label));
lines.push(" re:verificationStatus re:Claimed ;".to_string());
lines.push(format!(" prov:wasDerivedFrom tp:doc-{} .", source_local));
lines.push(String::new());
}
lines.join("\n")
}
fn sanitize_local(s: &str) -> String {
s.chars()
.map(|c| if c.is_alphanumeric() || c == '_' || c == '-' { c } else { '_' })
.collect::<String>()
.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_persons_regex() {
let text = "Le projet est dirigé par Jean Dupont — CTO et Marie Martin, Architect du SI.";
let results = extract_persons_regex(text, "test_doc");
assert!(results.len() >= 2, "Expected 2 persons, got {}", results.len());
assert!(results.iter().any(|e| e.label.contains("Jean Dupont")));
assert!(results.iter().any(|e| e.label.contains("Marie Martin")));
}
#[test]
fn test_extract_persons_uppercase_surname() {
let text = "# Support d'entretien — Gérald AROULANDA, CEO RiskHunter";
let results = extract_persons_regex(text, "test_doc");
assert!(!results.is_empty(), "Should extract Gérald AROULANDA");
let gerald = results.iter().find(|e| e.label.contains("Aroulanda"));
assert!(gerald.is_some(), "Should normalize AROULANDA → Aroulanda");
assert_eq!(gerald.unwrap().confidence, 1.0);
}
#[test]
fn test_normalize_person_name() {
assert_eq!(normalize_person_name("Gérald AROULANDA"), "Gérald Aroulanda");
assert_eq!(normalize_person_name("Jean Dupont"), "Jean Dupont");
assert_eq!(normalize_person_name("JEAN DUPONT"), "Jean Dupont");
}
#[test]
fn test_extract_orgs_regex() {
let text = "L'audit a été réalisé conformément aux recommandations de l'ANSSI et du NIST, avec le support de Thales.";
let results = extract_orgs_regex(text, "test_doc");
assert_eq!(results.len(), 3);
let labels: Vec<&str> = results.iter().map(|e| e.label.as_str()).collect();
assert!(labels.contains(&"ANSSI"));
assert!(labels.contains(&"NIST"));
assert!(labels.contains(&"Thales"));
}
#[test]
fn test_extract_capabilities_regex() {
let text = "module: Identity and Access Management\nfeature: multi-factor authentication\nservice: API gateway";
let results = extract_capabilities_regex(text, "test_doc");
assert_eq!(results.len(), 3, "Expected 3 capabilities, got {:?}", results);
}
#[test]
fn test_merge_symbolic_wins() {
let entities = vec![
SocialEntity {
entity_type: EntityType::Person,
label: "Jean Dupont".into(),
role: Some("CTO".into()),
source: "doc1".into(),
origin: EntityOrigin::Neural,
confidence: 0.85,
},
SocialEntity {
entity_type: EntityType::Person,
label: "Jean Dupont".into(),
role: Some("CTO".into()),
source: "doc2".into(),
origin: EntityOrigin::Symbolic,
confidence: 1.0,
},
];
let (merged, _, _) = merge_social_entities(entities);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].origin, EntityOrigin::Symbolic, "Symbolic should win");
}
#[test]
fn test_social_to_turtle() {
let result = SocialResult {
entities: vec![
SocialEntity {
entity_type: EntityType::Person,
label: "Jean Dupont".into(),
role: Some("CTO".into()),
source: "interview_01".into(),
origin: EntityOrigin::Symbolic,
confidence: 1.0,
},
SocialEntity {
entity_type: EntityType::Organisation,
label: "ANSSI".into(),
role: None,
source: "interview_01".into(),
origin: EntityOrigin::Symbolic,
confidence: 1.0,
},
],
documents_scanned: 1,
symbolic_count: 2,
neural_count: 0,
};
let config = SocialConfig::default();
let turtle = social_to_turtle(&result, &config);
assert!(turtle.contains("edgy:Person"));
assert!(turtle.contains("Jean Dupont"));
assert!(turtle.contains("edgy:Organisation"));
assert!(turtle.contains("ANSSI"));
assert!(turtle.contains("prov:wasDerivedFrom"));
}
}