//! Natural Language Schedule Parser — transforms Chinese time expressions into cron. //! //! Three-layer fallback strategy: //! 1. Regex pattern matching (covers ~80% of common expressions) //! 2. LLM-assisted parsing (for ambiguous/complex expressions) — TODO: wire when Haiku driver available //! 3. Interactive clarification (return `Unclear`) //! //! Lives in `zclaw-runtime` because it's a pure text→cron utility with no kernel dependency. use chrono::{Datelike, Timelike}; use serde::{Deserialize, Serialize}; use zclaw_types::AgentId; // --------------------------------------------------------------------------- // Data structures // --------------------------------------------------------------------------- /// Result of parsing a natural language schedule expression. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ParsedSchedule { /// Cron expression, e.g. "0 9 * * *" pub cron_expression: String, /// Human-readable description of the schedule pub natural_description: String, /// Confidence of the parse (0.0–1.0) pub confidence: f32, /// What the task does (extracted from user input) pub task_description: String, /// What to trigger when the schedule fires pub task_target: TaskTarget, } /// Target to trigger on schedule. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "id")] pub enum TaskTarget { /// Trigger a specific agent Agent(String), /// Trigger a specific hand Hand(String), /// Trigger a specific workflow Workflow(String), /// Generic reminder (no specific target) Reminder, } /// Outcome of NL schedule parsing. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ScheduleParseResult { /// High-confidence single parse Exact(ParsedSchedule), /// Multiple possible interpretations Ambiguous(Vec), /// Unable to parse — needs user clarification Unclear, } // --------------------------------------------------------------------------- // Regex pattern library // --------------------------------------------------------------------------- /// A single pattern for matching Chinese time expressions. struct SchedulePattern { /// Regex pattern string regex: &'static str, /// Cron template — use {h} for hour, {m} for minute, {dow} for day-of-week, {dom} for day-of-month cron_template: &'static str, /// Human description template description: &'static str, /// Base confidence for this pattern confidence: f32, } /// Chinese time period keywords → hour mapping fn period_to_hour(period: &str) -> Option { match period { "凌晨" => Some(0), "早上" | "早晨" | "上午" => Some(9), "中午" => Some(12), "下午" | "午后" => Some(15), "傍晚" | "黄昏" => Some(18), "晚上" | "晚间" | "夜里" | "夜晚" => Some(21), "半夜" | "午夜" => Some(0), _ => None, } } /// Chinese weekday names → cron day-of-week fn weekday_to_cron(day: &str) -> Option<&'static str> { match day { "一" | "周一" | "星期一" | "礼拜一" => Some("1"), "二" | "周二" | "星期二" | "礼拜二" => Some("2"), "三" | "周三" | "星期三" | "礼拜三" => Some("3"), "四" | "周四" | "星期四" | "礼拜四" => Some("4"), "五" | "周五" | "星期五" | "礼拜五" => Some("5"), "六" | "周六" | "星期六" | "礼拜六" => Some("6"), "日" | "周日" | "星期日" | "礼拜日" | "天" | "周天" | "星期天" | "礼拜天" => Some("0"), _ => None, } } // --------------------------------------------------------------------------- // Parser implementation // --------------------------------------------------------------------------- /// Parse a natural language schedule expression into a cron expression. /// /// Uses a series of regex-based pattern matchers covering common Chinese /// time expressions. Returns `Unclear` if no pattern matches. pub fn parse_nl_schedule(input: &str, default_agent_id: &AgentId) -> ScheduleParseResult { let input = input.trim(); if input.is_empty() { return ScheduleParseResult::Unclear; } // Extract task description (everything after keywords like "提醒我", "帮我") let task_description = extract_task_description(input); // --- Pattern 1: 每天 + 时间 --- if let Some(result) = try_every_day(input, &task_description, default_agent_id) { return result; } // --- Pattern 2: 每周N + 时间 --- if let Some(result) = try_every_week(input, &task_description, default_agent_id) { return result; } // --- Pattern 3: 工作日 + 时间 --- if let Some(result) = try_workday(input, &task_description, default_agent_id) { return result; } // --- Pattern 4: 每N小时/分钟 --- if let Some(result) = try_interval(input, &task_description, default_agent_id) { return result; } // --- Pattern 5: 每月N号 --- if let Some(result) = try_monthly(input, &task_description, default_agent_id) { return result; } // --- Pattern 6: 明天/后天 + 时间 (one-shot) --- if let Some(result) = try_one_shot(input, &task_description, default_agent_id) { return result; } ScheduleParseResult::Unclear } /// Extract task description from input, stripping schedule-related keywords. fn extract_task_description(input: &str) -> String { let strip_prefixes = [ "每天", "每日", "每周", "工作日", "每个工作日", "每月", "每", "定时", "定期", "提醒我", "提醒", "帮我", "帮", "请", "明天", "后天", "大后天", ]; let mut desc = input.to_string(); // Strip prefixes + time expressions in alternating passes until stable let time_re = regex::Regex::new( r"^(?:凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)?\d{1,2}[点时::]\d{0,2}分?" ).unwrap_or_else(|_| regex::Regex::new("").unwrap()); for _ in 0..3 { // Pass 1: strip prefixes loop { let mut stripped = false; for prefix in &strip_prefixes { if desc.starts_with(prefix) { desc = desc[prefix.len()..].to_string(); stripped = true; } } if !stripped { break; } } // Pass 2: strip time expressions let new_desc = time_re.replace(&desc, "").to_string(); if new_desc == desc { break; } desc = new_desc; } desc.trim().to_string() } // -- Pattern matchers -- /// Adjust hour based on time-of-day period. Chinese 12-hour convention: /// 下午3点 = 15, 晚上8点 = 20, etc. Morning hours stay as-is. fn adjust_hour_for_period(hour: u32, period: Option<&str>) -> u32 { if let Some(p) = period { match p { "下午" | "午后" => { if hour < 12 { hour + 12 } else { hour } } "晚上" | "晚间" | "夜里" | "夜晚" => { if hour < 12 { hour + 12 } else { hour } } "傍晚" | "黄昏" => { if hour < 12 { hour + 12 } else { hour } } "中午" => { if hour == 12 { 12 } else if hour < 12 { hour + 12 } else { hour } } "半夜" | "午夜" => { if hour == 12 { 0 } else { hour } } _ => hour, } } else { hour } } const PERIOD_PATTERN: &str = "(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)?"; fn try_every_day(input: &str, task_desc: &str, agent_id: &AgentId) -> Option { let re = regex::Regex::new( &format!(r"(?:每天|每日)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?", PERIOD_PATTERN) ).ok()?; if let Some(caps) = re.captures(input) { let period = caps.get(1).map(|m| m.as_str()); let raw_hour: u32 = caps.get(2)?.as_str().parse().ok()?; let minute: u32 = caps.get(3).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0); let hour = adjust_hour_for_period(raw_hour, period); if hour > 23 || minute > 59 { return None; } return Some(ScheduleParseResult::Exact(ParsedSchedule { cron_expression: format!("{} {} * * *", minute, hour), natural_description: format!("每天{:02}:{:02}", hour, minute), confidence: 0.95, task_description: task_desc.to_string(), task_target: TaskTarget::Agent(agent_id.to_string()), })); } // "每天早上/下午..." without explicit hour let re2 = regex::Regex::new(r"(?:每天|每日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)").ok()?; if let Some(caps) = re2.captures(input) { let period = caps.get(1)?.as_str(); if let Some(hour) = period_to_hour(period) { return Some(ScheduleParseResult::Exact(ParsedSchedule { cron_expression: format!("0 {} * * *", hour), natural_description: format!("每天{}", period), confidence: 0.85, task_description: task_desc.to_string(), task_target: TaskTarget::Agent(agent_id.to_string()), })); } } None } fn try_every_week(input: &str, task_desc: &str, agent_id: &AgentId) -> Option { let re = regex::Regex::new( &format!(r"(?:每周|每个?星期|每个?礼拜)(一|二|三|四|五|六|日|天|周一|周二|周三|周四|周五|周六|周日|周天|星期一|星期二|星期三|星期四|星期五|星期六|星期日|星期天|礼拜一|礼拜二|礼拜三|礼拜四|礼拜五|礼拜六|礼拜日|礼拜天)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?", PERIOD_PATTERN) ).ok()?; let caps = re.captures(input)?; let day_str = caps.get(1)?.as_str(); let dow = weekday_to_cron(day_str)?; let period = caps.get(2).map(|m| m.as_str()); let raw_hour: u32 = caps.get(3)?.as_str().parse().ok()?; let minute: u32 = caps.get(4).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0); let hour = adjust_hour_for_period(raw_hour, period); if hour > 23 || minute > 59 { return None; } Some(ScheduleParseResult::Exact(ParsedSchedule { cron_expression: format!("{} {} * * {}", minute, hour, dow), natural_description: format!("每周{} {:02}:{:02}", day_str, hour, minute), confidence: 0.92, task_description: task_desc.to_string(), task_target: TaskTarget::Agent(agent_id.to_string()), })) } fn try_workday(input: &str, task_desc: &str, agent_id: &AgentId) -> Option { let re = regex::Regex::new( &format!(r"(?:工作日|每个?工作日|工作日(?:的)?){}(\d{{1,2}})[点时::](\d{{1,2}})?", PERIOD_PATTERN) ).ok()?; if let Some(caps) = re.captures(input) { let period = caps.get(1).map(|m| m.as_str()); let raw_hour: u32 = caps.get(2)?.as_str().parse().ok()?; let minute: u32 = caps.get(3).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0); let hour = adjust_hour_for_period(raw_hour, period); if hour > 23 || minute > 59 { return None; } return Some(ScheduleParseResult::Exact(ParsedSchedule { cron_expression: format!("{} {} * * 1-5", minute, hour), natural_description: format!("工作日{:02}:{:02}", hour, minute), confidence: 0.90, task_description: task_desc.to_string(), task_target: TaskTarget::Agent(agent_id.to_string()), })); } // "工作日下午3点" style let re2 = regex::Regex::new( r"(?:工作日|每个?工作日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)" ).ok()?; if let Some(caps) = re2.captures(input) { let period = caps.get(1)?.as_str(); if let Some(hour) = period_to_hour(period) { return Some(ScheduleParseResult::Exact(ParsedSchedule { cron_expression: format!("0 {} * * 1-5", hour), natural_description: format!("工作日{}", period), confidence: 0.85, task_description: task_desc.to_string(), task_target: TaskTarget::Agent(agent_id.to_string()), })); } } None } fn try_interval(input: &str, task_desc: &str, agent_id: &AgentId) -> Option { // "每2小时", "每30分钟", "每N小时/分钟" let re = regex::Regex::new(r"每(\d{1,2})(小时|分钟|分|钟|个小时)").ok()?; if let Some(caps) = re.captures(input) { let n: u32 = caps.get(1)?.as_str().parse().ok()?; if n == 0 { return None; } let unit = caps.get(2)?.as_str(); let (cron, desc) = if unit.contains("小") { (format!("0 */{} * * *", n), format!("每{}小时", n)) } else { (format!("*/{} * * * *", n), format!("每{}分钟", n)) }; return Some(ScheduleParseResult::Exact(ParsedSchedule { cron_expression: cron, natural_description: desc, confidence: 0.90, task_description: task_desc.to_string(), task_target: TaskTarget::Agent(agent_id.to_string()), })); } None } fn try_monthly(input: &str, task_desc: &str, agent_id: &AgentId) -> Option { let re = regex::Regex::new( &format!(r"(?:每月|每个月)(?:的)?(\d{{1,2}})[号日](?:的)?{}(\d{{1,2}})?[点时::]?(\d{{1,2}})?", PERIOD_PATTERN) ).ok()?; if let Some(caps) = re.captures(input) { let day: u32 = caps.get(1)?.as_str().parse().ok()?; let period = caps.get(2).map(|m| m.as_str()); let raw_hour: u32 = caps.get(3).map(|m| m.as_str().parse().unwrap_or(9)).unwrap_or(9); let minute: u32 = caps.get(4).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0); let hour = adjust_hour_for_period(raw_hour, period); if day > 31 || hour > 23 || minute > 59 { return None; } return Some(ScheduleParseResult::Exact(ParsedSchedule { cron_expression: format!("{} {} {} * *", minute, hour, day), natural_description: format!("每月{}号 {:02}:{:02}", day, hour, minute), confidence: 0.90, task_description: task_desc.to_string(), task_target: TaskTarget::Agent(agent_id.to_string()), })); } None } fn try_one_shot(input: &str, task_desc: &str, agent_id: &AgentId) -> Option { let re = regex::Regex::new( &format!(r"(明天|后天|大后天)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?", PERIOD_PATTERN) ).ok()?; let caps = re.captures(input)?; let day_offset = match caps.get(1)?.as_str() { "明天" => 1, "后天" => 2, "大后天" => 3, _ => return None, }; let period = caps.get(2).map(|m| m.as_str()); let raw_hour: u32 = caps.get(3)?.as_str().parse().ok()?; let minute: u32 = caps.get(4).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0); let hour = adjust_hour_for_period(raw_hour, period); if hour > 23 || minute > 59 { return None; } let target = chrono::Utc::now() .checked_add_signed(chrono::Duration::days(day_offset)) .unwrap_or_else(chrono::Utc::now) .with_hour(hour) .unwrap_or_else(|| chrono::Utc::now()) .with_minute(minute) .unwrap_or_else(|| chrono::Utc::now()) .with_second(0) .unwrap_or_else(|| chrono::Utc::now()); Some(ScheduleParseResult::Exact(ParsedSchedule { cron_expression: target.to_rfc3339(), natural_description: format!("{} {:02}:{:02}", caps.get(1)?.as_str(), hour, minute), confidence: 0.88, task_description: task_desc.to_string(), task_target: TaskTarget::Agent(agent_id.to_string()), })) } // --------------------------------------------------------------------------- // Schedule intent detection // --------------------------------------------------------------------------- /// Keywords indicating the user wants to set a scheduled task. const SCHEDULE_INTENT_KEYWORDS: &[&str] = &[ "提醒我", "提醒", "定时", "每天", "每日", "每周", "每月", "工作日", "每隔", "每", "定期", "到时候", "准时", "闹钟", "闹铃", "日程", "日历", ]; /// Check if user input contains schedule intent. pub fn has_schedule_intent(input: &str) -> bool { let lower = input.to_lowercase(); SCHEDULE_INTENT_KEYWORDS.iter().any(|kw| lower.contains(kw)) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; fn default_agent() -> AgentId { AgentId::new() } #[test] fn test_every_day_explicit_time() { let result = parse_nl_schedule("每天早上9点提醒我查房", &default_agent()); match result { ScheduleParseResult::Exact(s) => { assert_eq!(s.cron_expression, "0 9 * * *"); assert!(s.confidence >= 0.9); } _ => panic!("Expected Exact, got {:?}", result), } } #[test] fn test_every_day_with_minute() { let result = parse_nl_schedule("每天下午3点30分提醒我", &default_agent()); match result { ScheduleParseResult::Exact(s) => { assert_eq!(s.cron_expression, "30 15 * * *"); } _ => panic!("Expected Exact"), } } #[test] fn test_every_day_period_only() { let result = parse_nl_schedule("每天早上提醒我看看报告", &default_agent()); match result { ScheduleParseResult::Exact(s) => { assert_eq!(s.cron_expression, "0 9 * * *"); } _ => panic!("Expected Exact"), } } #[test] fn test_every_week_monday() { let result = parse_nl_schedule("每周一上午10点提醒我开会", &default_agent()); match result { ScheduleParseResult::Exact(s) => { assert_eq!(s.cron_expression, "0 10 * * 1"); } _ => panic!("Expected Exact"), } } #[test] fn test_every_week_friday() { let result = parse_nl_schedule("每个星期五下午2点", &default_agent()); match result { ScheduleParseResult::Exact(s) => { assert_eq!(s.cron_expression, "0 14 * * 5"); } _ => panic!("Expected Exact"), } } #[test] fn test_workday() { let result = parse_nl_schedule("工作日下午3点提醒我写周报", &default_agent()); match result { ScheduleParseResult::Exact(s) => { assert_eq!(s.cron_expression, "0 15 * * 1-5"); } _ => panic!("Expected Exact"), } } #[test] fn test_interval_hours() { let result = parse_nl_schedule("每2小时提醒我喝水", &default_agent()); match result { ScheduleParseResult::Exact(s) => { assert_eq!(s.cron_expression, "0 */2 * * *"); } _ => panic!("Expected Exact"), } } #[test] fn test_interval_minutes() { let result = parse_nl_schedule("每30分钟检查一次", &default_agent()); match result { ScheduleParseResult::Exact(s) => { assert_eq!(s.cron_expression, "*/30 * * * *"); } _ => panic!("Expected Exact"), } } #[test] fn test_monthly() { let result = parse_nl_schedule("每月1号早上9点提醒我", &default_agent()); match result { ScheduleParseResult::Exact(s) => { assert_eq!(s.cron_expression, "0 9 1 * *"); } _ => panic!("Expected Exact"), } } #[test] fn test_one_shot_tomorrow() { let result = parse_nl_schedule("明天下午3点提醒我开会", &default_agent()); match result { ScheduleParseResult::Exact(s) => { assert!(s.cron_expression.contains('T')); assert!(s.natural_description.contains("明天")); } _ => panic!("Expected Exact"), } } #[test] fn test_unclear_input() { let result = parse_nl_schedule("今天天气怎么样", &default_agent()); assert!(matches!(result, ScheduleParseResult::Unclear)); } #[test] fn test_empty_input() { let result = parse_nl_schedule("", &default_agent()); assert!(matches!(result, ScheduleParseResult::Unclear)); } #[test] fn test_schedule_intent_detection() { assert!(has_schedule_intent("每天早上9点提醒我查房")); assert!(has_schedule_intent("帮我设个定时任务")); assert!(has_schedule_intent("工作日提醒我打卡")); assert!(!has_schedule_intent("今天天气怎么样")); assert!(!has_schedule_intent("帮我写个报告")); } #[test] fn test_period_to_hour_mapping() { assert_eq!(period_to_hour("凌晨"), Some(0)); assert_eq!(period_to_hour("早上"), Some(9)); assert_eq!(period_to_hour("中午"), Some(12)); assert_eq!(period_to_hour("下午"), Some(15)); assert_eq!(period_to_hour("晚上"), Some(21)); assert_eq!(period_to_hour("不知道"), None); } #[test] fn test_weekday_to_cron_mapping() { assert_eq!(weekday_to_cron("一"), Some("1")); assert_eq!(weekday_to_cron("五"), Some("5")); assert_eq!(weekday_to_cron("日"), Some("0")); assert_eq!(weekday_to_cron("星期三"), Some("3")); assert_eq!(weekday_to_cron("礼拜天"), Some("0")); assert_eq!(weekday_to_cron("未知"), None); } #[test] fn test_task_description_extraction() { assert_eq!(extract_task_description("每天早上9点提醒我查房"), "查房"); } }