minutes-core 0.22.0

Core library for minutes — audio capture, transcription, and meeting memory
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum MeetingMode {
    Sales,
    Discovery,
    Interview,
    Negotiation,
    DifficultConversation,
    Decision,
    #[default]
    Generic,
}

impl MeetingMode {
    pub const ALL: [&'static str; 7] = [
        "sales",
        "discovery",
        "interview",
        "negotiation",
        "difficult-conversation",
        "decision",
        "generic",
    ];

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Sales => "sales",
            Self::Discovery => "discovery",
            Self::Interview => "interview",
            Self::Negotiation => "negotiation",
            Self::DifficultConversation => "difficult-conversation",
            Self::Decision => "decision",
            Self::Generic => "generic",
        }
    }

    pub fn policy(self) -> MeetingModePolicy {
        let (minimum_confidence, minimum_interval_ms, tone) = match self {
            Self::Sales => (
                65,
                4_000,
                "concise, commercially aware, and useful without sounding scripted",
            ),
            Self::Discovery => (
                55,
                6_000,
                "curious and open-ended; favor learning over premature solutions",
            ),
            Self::Interview => (
                70,
                8_000,
                "neutral and evidence-seeking; avoid leading the candidate",
            ),
            Self::Negotiation => (
                75,
                12_000,
                "calm, precise, and leverage-aware; never manufacture urgency",
            ),
            Self::DifficultConversation => (
                75,
                15_000,
                "empathetic, de-escalating, and direct without assigning motives",
            ),
            Self::Decision => (
                65,
                5_000,
                "crisp and outcome-oriented; surface tradeoffs, ownership, and closure",
            ),
            Self::Generic => (
                0,
                0,
                "brief, grounded, and low-pressure; intervene only when clearly useful",
            ),
        };
        MeetingModePolicy {
            mode: self,
            minimum_confidence,
            minimum_interval_ms,
            tone,
        }
    }
}

impl fmt::Display for MeetingMode {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for MeetingMode {
    type Err = String;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "sales" => Ok(Self::Sales),
            "discovery" => Ok(Self::Discovery),
            "interview" => Ok(Self::Interview),
            "negotiation" => Ok(Self::Negotiation),
            "difficult-conversation" | "difficult_conversation" => Ok(Self::DifficultConversation),
            "decision" => Ok(Self::Decision),
            "generic" | "" => Ok(Self::Generic),
            other => Err(format!(
                "unsupported copilot mode '{other}'; use {}",
                Self::ALL.join(", ")
            )),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum OpportunityKind {
    Pain,
    Objection,
    NextStep,
    Evidence,
    Decision,
    Leverage,
    Rapport,
    Clarity,
    Safety,
    #[default]
    General,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MeetingModePolicy {
    pub mode: MeetingMode,
    pub minimum_confidence: u8,
    pub minimum_interval_ms: u64,
    pub tone: &'static str,
}

impl MeetingModePolicy {
    pub fn allows(self, opportunity: OpportunityKind) -> bool {
        use OpportunityKind::*;
        opportunity == General
            || match self.mode {
                MeetingMode::Sales => {
                    matches!(
                        opportunity,
                        Pain | Objection | NextStep | Decision | Clarity
                    )
                }
                MeetingMode::Discovery => {
                    matches!(opportunity, Pain | Objection | Evidence | Rapport | Clarity)
                }
                MeetingMode::Interview => {
                    matches!(opportunity, Evidence | Rapport | Clarity | Safety)
                }
                MeetingMode::Negotiation => {
                    matches!(
                        opportunity,
                        Objection | Decision | Leverage | Clarity | Safety
                    )
                }
                MeetingMode::DifficultConversation => {
                    matches!(opportunity, Rapport | Clarity | Safety | NextStep)
                }
                MeetingMode::Decision => {
                    matches!(
                        opportunity,
                        NextStep | Evidence | Decision | Clarity | Safety
                    )
                }
                MeetingMode::Generic => true,
            }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn modes_parse_and_expose_distinct_policies() {
        assert_eq!(
            "difficult-conversation".parse(),
            Ok(MeetingMode::DifficultConversation)
        );
        assert!(MeetingMode::Discovery
            .policy()
            .allows(OpportunityKind::Pain));
        assert!(!MeetingMode::Decision.policy().allows(OpportunityKind::Pain));
        assert!(
            MeetingMode::Negotiation.policy().minimum_interval_ms
                > MeetingMode::Sales.policy().minimum_interval_ms
        );
    }
}