feat(hands,desktop): C线差异化 — 管家日报 + 零配置引导优化
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

C1 管家日报:
- 新增 _daily_report Hand (daily_report.rs) — 5个测试
- 增强 user_profile_store — PainPoint 结构体 + find_active_pains_since + resolve_pain
- experience_store 新增 find_since 日期范围查询
- trajectory_store 新增 get_events_since 日期范围查询
- 新增 DailyReportPanel.tsx 前端日报面板
- Sidebar 新增"日报"导航入口

C3 零配置引导:
- 修复行业卡点击后阶段推进 bug (industry_discovery → identity_setup)

验证: 940 tests PASS, 0 failures
This commit is contained in:
iven
2026-04-21 18:23:36 +08:00
parent a43806ccc2
commit ae56aba366
8 changed files with 864 additions and 44 deletions

View File

@@ -0,0 +1,241 @@
//! 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 news placeholder
//!
//! Emits `daily-report:ready` Tauri event and persists 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::Local::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);
}
}

View File

@@ -15,6 +15,7 @@ mod collector;
mod clip;
mod twitter;
pub mod reminder;
pub mod daily_report;
pub use quiz::*;
pub use browser::*;
@@ -23,3 +24,4 @@ pub use collector::*;
pub use clip::*;
pub use twitter::*;
pub use reminder::*;
pub use daily_report::*;