Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CRITICAL: - user_profile_store: find_active_pains_since 改为 find_active_pains, 移除无意义 .filter(|_| true),不再伪造 created_at=since HIGH: - daily_report: 移除虚假的 "Emits Tauri event" 注释(事件发射是调用方职责) - daily_report: chrono::Local → chrono::Utc 一致性修复 - 新增 8 个单元测试: PainPoint 系列测试 + find_since + get_events_since 验证: zclaw-memory 54 PASS, zclaw-growth 151 PASS, zclaw-hands 5 PASS
245 lines
7.9 KiB
Rust
245 lines
7.9 KiB
Rust
//! 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<HandResult> {
|
|
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::<Vec<_>>()
|
|
})
|
|
.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::<Vec<_>>()
|
|
})
|
|
.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);
|
|
}
|
|
}
|