use std::sync::{Arc, OnceLock, RwLock};
use serde::{Deserialize, Serialize};
use crate::emcp::EpistemicTaint;
use crate::tool_executor::ToolResult;
use crate::tool_registry::ToolEntry;
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ContactQuery {
#[serde(default)]
pub name: String,
#[serde(default)]
pub company: String,
#[serde(default)]
pub domain: String,
#[serde(default)]
pub linkedin: String,
}
impl ContactQuery {
pub fn is_empty(&self) -> bool {
self.name.trim().is_empty()
&& self.company.trim().is_empty()
&& self.domain.trim().is_empty()
&& self.linkedin.trim().is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EnrichmentLevel {
Speculate,
Believe,
}
impl EnrichmentLevel {
pub fn from_confidence(confidence: f64) -> Self {
if confidence >= 0.85 {
EnrichmentLevel::Believe
} else {
EnrichmentLevel::Speculate
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnrichedField {
pub value: String,
pub confidence: f64,
pub level: EnrichmentLevel,
}
impl EnrichedField {
pub fn new(value: impl Into<String>, confidence: f64) -> Self {
let c = if confidence.is_nan() { 0.0 } else { confidence.clamp(0.0, 1.0) };
EnrichedField {
value: value.into(),
confidence: c,
level: EnrichmentLevel::from_confidence(c),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EnrichmentResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<EnrichedField>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<EnrichedField>,
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin: Option<EnrichedField>,
pub provider: String,
}
impl EnrichmentResult {
pub fn resolved_count(&self) -> usize {
self.email.is_some() as usize
+ self.phone.is_some() as usize
+ self.linkedin.is_some() as usize
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnrichmentError {
NoProviderConfigured,
MissingQuery,
ProviderFailed(String),
QuotaExceeded,
}
impl std::fmt::Display for EnrichmentError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EnrichmentError::NoProviderConfigured => write!(
f,
"enrich: no enrichment provider is configured — the OSS build has no engine \
(this is a typed refusal, never a fabricated contact)"
),
EnrichmentError::MissingQuery => write!(
f,
"enrich: the query carried no identifying field (need one of name/company/domain/linkedin)"
),
EnrichmentError::ProviderFailed(e) => write!(f, "enrich: provider failed: {e}"),
EnrichmentError::QuotaExceeded => write!(f, "enrich: vendor quota exhausted"),
}
}
}
impl std::error::Error for EnrichmentError {}
pub trait EnrichmentProvider: Send + Sync {
fn name(&self) -> &str;
fn enrich(&self, query: &ContactQuery) -> Result<EnrichmentResult, EnrichmentError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoProvider;
impl EnrichmentProvider for NoProvider {
fn name(&self) -> &str {
"none"
}
fn enrich(&self, _query: &ContactQuery) -> Result<EnrichmentResult, EnrichmentError> {
Err(EnrichmentError::NoProviderConfigured)
}
}
fn registry() -> &'static RwLock<Option<Arc<dyn EnrichmentProvider>>> {
static REG: OnceLock<RwLock<Option<Arc<dyn EnrichmentProvider>>>> = OnceLock::new();
REG.get_or_init(|| RwLock::new(None))
}
pub fn register_provider(provider: Arc<dyn EnrichmentProvider>) {
*registry().write().expect("enrichment registry poisoned") = Some(provider);
}
pub fn clear_provider() {
*registry().write().expect("enrichment registry poisoned") = None;
}
pub fn active_provider() -> Option<Arc<dyn EnrichmentProvider>> {
registry().read().expect("enrichment registry poisoned").clone()
}
fn run_active(query: &ContactQuery) -> Result<EnrichmentResult, EnrichmentError> {
if query.is_empty() {
return Err(EnrichmentError::MissingQuery);
}
match active_provider() {
Some(p) => p.enrich(query),
None => Err(EnrichmentError::NoProviderConfigured),
}
}
#[derive(Debug, Clone)]
pub struct EnrichmentOutcome {
pub result: ToolResult,
pub taint: EpistemicTaint,
}
impl EnrichmentOutcome {
fn ok(tool_name: &str, output: String) -> Self {
EnrichmentOutcome {
result: ToolResult { success: true, output, tool_name: tool_name.to_string() },
taint: EpistemicTaint::Untrusted,
}
}
fn err(tool_name: &str, e: EnrichmentError) -> Self {
EnrichmentOutcome {
result: ToolResult { success: false, output: e.to_string(), tool_name: tool_name.to_string() },
taint: EpistemicTaint::Untrusted,
}
}
}
pub fn dispatch_enrich(entry: &ToolEntry, argument: &str) -> ToolResult {
dispatch_enrich_outcome(entry, argument).result
}
pub fn dispatch_enrich_outcome(entry: &ToolEntry, argument: &str) -> EnrichmentOutcome {
let query: ContactQuery = match serde_json::from_str(argument) {
Ok(q) => q,
Err(_) => ContactQuery::default(),
};
match run_active(&query) {
Ok(result) => match serde_json::to_string(&result) {
Ok(json) => EnrichmentOutcome::ok(&entry.name, json),
Err(e) => EnrichmentOutcome::err(&entry.name, EnrichmentError::ProviderFailed(format!("encode: {e}"))),
},
Err(e) => EnrichmentOutcome::err(&entry.name, e),
}
}
#[cfg(test)]
mod tests {
use super::*;
static REG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn entry() -> ToolEntry {
ToolEntry {
name: "Enrich".into(),
provider: "scrape_enrich".into(),
timeout: String::new(),
runtime: String::new(),
sandbox: None,
max_results: None,
output_schema: String::new(),
effect_row: vec!["network".into(), "web".into()],
parameters: Vec::new(),
secret: String::new(),
secret_partition: String::new(),
source: crate::tool_registry::ToolSource::Program,
is_streaming: false,
scrape: None,
}
}
#[test]
fn no_provider_is_a_typed_refusal_never_a_fabricated_contact() {
let _g = REG_LOCK.lock().unwrap();
clear_provider();
let out = dispatch_enrich_outcome(&entry(), r#"{"name":"Ada","company":"acme.com"}"#);
assert!(!out.result.success);
assert!(out.result.output.contains("no enrichment provider"));
assert_eq!(out.taint, EpistemicTaint::Untrusted);
}
#[test]
fn empty_query_is_missing_query() {
let _g = REG_LOCK.lock().unwrap();
clear_provider();
let out = dispatch_enrich_outcome(&entry(), "{}");
assert!(!out.result.success);
assert!(out.result.output.contains("no identifying field"));
}
#[test]
fn the_believe_ceiling_holds_a_high_confidence_field_at_believe_not_know() {
let f = EnrichedField::new("ada@acme.com", 1.0);
assert_eq!(f.level, EnrichmentLevel::Believe);
assert!((f.confidence - 1.0).abs() < f64::EPSILON);
assert_eq!(EnrichedField::new("guess@acme.com", 0.4).level, EnrichmentLevel::Speculate);
}
#[test]
fn a_registered_provider_result_serialises_and_is_born_untrusted() {
let _g = REG_LOCK.lock().unwrap();
struct Mock;
impl EnrichmentProvider for Mock {
fn name(&self) -> &str {
"mock"
}
fn enrich(&self, _q: &ContactQuery) -> Result<EnrichmentResult, EnrichmentError> {
Ok(EnrichmentResult {
email: Some(EnrichedField::new("ada@acme.com", 0.9)),
phone: None,
linkedin: None,
provider: "mock".into(),
})
}
}
register_provider(Arc::new(Mock));
let out = dispatch_enrich_outcome(&entry(), r#"{"name":"Ada","domain":"acme.com"}"#);
assert!(out.result.success, "output: {}", out.result.output);
assert_eq!(out.taint, EpistemicTaint::Untrusted);
let v: serde_json::Value = serde_json::from_str(&out.result.output).unwrap();
assert_eq!(v["email"]["value"], "ada@acme.com");
assert_eq!(v["email"]["level"], "believe");
assert_eq!(v["provider"], "mock");
clear_provider();
}
#[test]
fn resolved_count_counts_present_fields_only() {
let r = EnrichmentResult {
email: Some(EnrichedField::new("a@b.com", 0.9)),
phone: None,
linkedin: Some(EnrichedField::new("in/ada", 0.7)),
provider: "mock".into(),
};
assert_eq!(r.resolved_count(), 2);
}
}