tandem-server 0.4.18

HTTP server for Tandem engine APIs
Documentation
use super::*;

pub(crate) fn automation_node_builder_metadata(
    node: &AutomationFlowNode,
    key: &str,
) -> Option<String> {
    node.metadata
        .as_ref()
        .and_then(|metadata| metadata.get("builder"))
        .and_then(|builder| builder.get(key))
        .and_then(Value::as_str)
        .map(str::to_string)
}

pub(crate) fn automation_node_research_stage(node: &AutomationFlowNode) -> Option<String> {
    automation_node_builder_metadata(node, "research_stage")
        .map(|value| value.trim().to_ascii_lowercase())
        .filter(|value| !value.is_empty())
}

pub(crate) fn automation_node_is_research_finalize(node: &AutomationFlowNode) -> bool {
    automation_node_research_stage(node).as_deref() == Some("research_finalize")
}

pub(crate) fn automation_node_is_outbound_action(node: &AutomationFlowNode) -> bool {
    if node
        .metadata
        .as_ref()
        .and_then(|value| value.pointer("/builder/role"))
        .and_then(Value::as_str)
        .is_some_and(|role| role.eq_ignore_ascii_case("publisher"))
    {
        return true;
    }
    let objective = node.objective.to_ascii_lowercase();
    [
        "publish", "post ", "send ", "notify", "deliver", "submit", "share",
    ]
    .iter()
    .any(|needle| objective.contains(needle))
}

pub(crate) fn automation_node_uses_upstream_validation_evidence(node: &AutomationFlowNode) -> bool {
    if automation_node_is_research_finalize(node) {
        return true;
    }
    let has_upstream_inputs = !node.input_refs.is_empty() || !node.depends_on.is_empty();
    if !has_upstream_inputs {
        return false;
    }
    if automation_node_requires_email_delivery(node) {
        return true;
    }
    let contract_kind = node
        .output_contract
        .as_ref()
        .map(|contract| contract.kind.trim().to_ascii_lowercase())
        .unwrap_or_default();
    matches!(
        contract_kind.as_str(),
        "brief" | "report_markdown" | "text_summary" | "review_summary" | "approval_gate"
    )
}

pub(crate) fn automation_node_preserves_full_upstream_inputs(node: &AutomationFlowNode) -> bool {
    if !automation_node_uses_upstream_validation_evidence(node) {
        return false;
    }
    matches!(
        node.output_contract
            .as_ref()
            .map(|contract| contract.kind.trim().to_ascii_lowercase())
            .as_deref(),
        Some("report_markdown" | "text_summary")
    ) || automation_node_requires_email_delivery(node)
}

pub(crate) fn automation_node_delivery_method(node: &AutomationFlowNode) -> Option<String> {
    node.metadata
        .as_ref()
        .and_then(|value| {
            value
                .pointer("/delivery/method")
                .or_else(|| value.pointer("/builder/delivery/method"))
        })
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_ascii_lowercase)
}

pub(crate) fn automation_node_delivery_target(node: &AutomationFlowNode) -> Option<String> {
    node.metadata
        .as_ref()
        .and_then(|value| {
            value
                .pointer("/delivery/to")
                .or_else(|| value.pointer("/builder/delivery/to"))
        })
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_string)
        .or_else(|| extract_email_address_from_text(&node.objective))
}

pub(crate) fn extract_email_address_from_text(text: &str) -> Option<String> {
    text.split_whitespace().find_map(|token| {
        let candidate = token
            .trim_matches(|ch: char| {
                ch.is_ascii_punctuation() && ch != '@' && ch != '.' && ch != '_' && ch != '-'
            })
            .trim();
        if candidate.is_empty()
            || !candidate.contains('@')
            || candidate.starts_with('@')
            || candidate.ends_with('@')
        {
            return None;
        }
        let mut parts = candidate.split('@');
        let local = parts.next()?.trim();
        let domain = parts.next()?.trim();
        if parts.next().is_some()
            || local.is_empty()
            || domain.is_empty()
            || !domain.contains('.')
            || domain.starts_with('.')
            || domain.ends_with('.')
        {
            return None;
        }
        Some(candidate.to_string())
    })
}

pub(crate) fn automation_node_email_content_type(node: &AutomationFlowNode) -> Option<String> {
    node.metadata
        .as_ref()
        .and_then(|value| {
            value
                .pointer("/delivery/content_type")
                .or_else(|| value.pointer("/builder/delivery/content_type"))
        })
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_string)
}

pub(crate) fn automation_node_inline_body_only(node: &AutomationFlowNode) -> Option<bool> {
    node.metadata
        .as_ref()
        .and_then(|value| {
            value
                .pointer("/delivery/inline_body_only")
                .or_else(|| value.pointer("/builder/delivery/inline_body_only"))
        })
        .and_then(Value::as_bool)
}

pub(crate) fn automation_node_allows_attachments(node: &AutomationFlowNode) -> Option<bool> {
    node.metadata
        .as_ref()
        .and_then(|value| {
            value
                .pointer("/delivery/attachments")
                .or_else(|| value.pointer("/builder/delivery/attachments"))
        })
        .and_then(Value::as_bool)
}

pub(crate) fn automation_node_requires_email_delivery(node: &AutomationFlowNode) -> bool {
    if automation_node_delivery_method(node)
        .as_deref()
        .is_some_and(|method| method == "email")
    {
        return true;
    }
    if !automation_node_is_outbound_action(node) {
        return false;
    }
    let objective = node.objective.to_ascii_lowercase();
    let contains_phrase = [
        "send email",
        "send the email",
        "send by email",
        "send the report by email",
        "email the ",
        "email report",
        "draft email",
        "draft the email",
        "gmail draft",
        "gmail_send",
        "notify by email",
        "notify the operator by email",
    ]
    .iter()
    .any(|needle| objective.contains(needle));
    if contains_phrase {
        return true;
    }

    let mentions_email_channel = objective.contains("email")
        || objective.contains("gmail")
        || objective.contains("mail tool")
        || objective.contains("mail tools");
    let mentions_delivery_action = objective.contains("send")
        || objective.contains("draft")
        || objective.contains("notify")
        || objective.contains("deliver");

    mentions_email_channel && mentions_delivery_action
}