//! Daily Report Hand — generates a personalized daily briefing. //! //! System hand (`_daily_report`) triggered by SchedulerService at 09:00 cron. //! Produces a Markdown daily report containing: //! 1. Yesterday's conversation summary //! 2. Unresolved pain points follow-up //! 3. Recent experience highlights //! 4. Industry-specific daily reminder //! //! The caller (SchedulerService or Tauri command) is responsible for: //! - Assembling input data (trajectory summary, pain points, experiences) //! - Emitting `daily-report:ready` Tauri event after execution //! - Persisting the report to VikingStorage use async_trait::async_trait; use serde_json::Value; use zclaw_types::Result; use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus}; /// Internal daily report hand. pub struct DailyReportHand { config: HandConfig, } impl DailyReportHand { pub fn new() -> Self { Self { config: HandConfig { id: "_daily_report".to_string(), name: "管家日报".to_string(), description: "Generates personalized daily briefing".to_string(), needs_approval: false, dependencies: vec![], input_schema: None, tags: vec!["system".to_string()], enabled: true, max_concurrent: 0, timeout_secs: 0, }, } } } #[async_trait] impl Hand for DailyReportHand { fn config(&self) -> &HandConfig { &self.config } async fn execute(&self, _context: &HandContext, input: Value) -> Result { let agent_id = input .get("agent_id") .and_then(|v| v.as_str()) .unwrap_or("default_user"); let industry = input .get("industry") .and_then(|v| v.as_str()) .unwrap_or(""); let trajectory_summary = input .get("trajectory_summary") .and_then(|v| v.as_str()) .unwrap_or("昨日无对话记录"); let pain_points = input .get("pain_points") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) .collect::>() }) .unwrap_or_default(); let recent_experiences = input .get("recent_experiences") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) .collect::>() }) .unwrap_or_default(); let report = self.build_report(industry, trajectory_summary, &pain_points, &recent_experiences); tracing::info!( "[DailyReportHand] Generated report for agent {} ({} pains, {} experiences)", agent_id, pain_points.len(), recent_experiences.len(), ); Ok(HandResult::success(serde_json::json!({ "agent_id": agent_id, "report": report, "pain_count": pain_points.len(), "experience_count": recent_experiences.len(), }))) } fn status(&self) -> HandStatus { HandStatus::Idle } } impl DailyReportHand { fn build_report( &self, industry: &str, trajectory_summary: &str, pain_points: &[String], recent_experiences: &[String], ) -> String { let industry_label = match industry { "healthcare" => "医疗行政", "education" => "教育培训", "garment" => "制衣制造", "ecommerce" => "电商零售", _ => "综合", }; let date = chrono::Utc::now().format("%Y年%m月%d日").to_string(); let mut sections = vec![ format!("# {} 管家日报 — {}", industry_label, date), String::new(), "## 昨日对话摘要".to_string(), trajectory_summary.to_string(), String::new(), ]; if !pain_points.is_empty() { sections.push("## 待解决问题".to_string()); for (i, pain) in pain_points.iter().enumerate() { sections.push(format!("{}. {}", i + 1, pain)); } sections.push(String::new()); } if !recent_experiences.is_empty() { sections.push("## 昨日收获".to_string()); for exp in recent_experiences { sections.push(format!("- {}", exp)); } sections.push(String::new()); } sections.push("## 今日提醒".to_string()); sections.push(self.daily_reminder(industry)); sections.push(String::new()); sections.push("祝你今天工作顺利!".to_string()); sections.join("\n") } fn daily_reminder(&self, industry: &str) -> String { match industry { "healthcare" => "记得检查今日科室排班,关注耗材库存预警。".to_string(), "education" => "今日有课程安排吗?提前准备教学材料。".to_string(), "garment" => "关注今日生产进度,及时跟进订单交期。".to_string(), "ecommerce" => "检查库存预警和待发货订单,把握促销节奏。".to_string(), _ => "新的一天,新的开始。有什么需要我帮忙的随时说。".to_string(), } } } #[cfg(test)] mod tests { use super::*; use zclaw_types::AgentId; #[test] fn test_build_report_basic() { let hand = DailyReportHand::new(); let report = hand.build_report( "healthcare", "讨论了科室排班问题", &["排班冲突".to_string()], &["学会了用数据报表工具".to_string()], ); assert!(report.contains("医疗行政")); assert!(report.contains("排班冲突")); assert!(report.contains("学会了用数据报表工具")); } #[test] fn test_build_report_empty() { let hand = DailyReportHand::new(); let report = hand.build_report("", "昨日无对话记录", &[], &[]); assert!(report.contains("管家日报")); assert!(report.contains("综合")); } #[test] fn test_build_report_all_industries() { let hand = DailyReportHand::new(); for industry in &["healthcare", "education", "garment", "ecommerce", "unknown"] { let report = hand.build_report(industry, "test", &[], &[]); assert!(!report.is_empty()); } } #[tokio::test] async fn test_execute_with_data() { let hand = DailyReportHand::new(); let ctx = HandContext { agent_id: AgentId::new(), working_dir: None, env: std::collections::HashMap::new(), timeout_secs: 30, callback_url: None, }; let input = serde_json::json!({ "agent_id": "test-agent", "industry": "education", "trajectory_summary": "讨论了课程安排", "pain_points": ["学生成绩下降"], "recent_experiences": ["掌握了成绩分析方法"], }); let result = hand.execute(&ctx, input).await.unwrap(); assert!(result.success); let output = result.output; assert_eq!(output["agent_id"], "test-agent"); assert!(output["report"].as_str().unwrap().contains("教育培训")); } #[tokio::test] async fn test_execute_minimal() { let hand = DailyReportHand::new(); let ctx = HandContext { agent_id: AgentId::new(), working_dir: None, env: std::collections::HashMap::new(), timeout_secs: 30, callback_url: None, }; let result = hand.execute(&ctx, serde_json::json!({})).await.unwrap(); assert!(result.success); } }