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
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:
@@ -248,6 +248,20 @@ impl ExperienceStore {
|
||||
debug!("[ExperienceStore] Deleted experience {} for agent {}", exp.id, exp.agent_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find experiences for an agent created since the given datetime.
|
||||
/// Filters by deserializing each entry and checking `created_at`.
|
||||
pub async fn find_since(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
since: DateTime<Utc>,
|
||||
) -> zclaw_types::Result<Vec<Experience>> {
|
||||
let all = self.find_by_agent(agent_id).await?;
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|exp| exp.created_at >= since)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
241
crates/zclaw-hands/src/hands/daily_report.rs
Normal file
241
crates/zclaw-hands/src/hands/daily_report.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -398,6 +398,49 @@ impl TrajectoryStore {
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// Get trajectory events for an agent created since the given datetime.
|
||||
pub async fn get_events_since(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
since: DateTime<Utc>,
|
||||
) -> Result<Vec<TrajectoryEvent>> {
|
||||
let rows = sqlx::query_as::<_, (String, String, String, i64, String, Option<String>, Option<String>, Option<i64>, String)>(
|
||||
r#"
|
||||
SELECT id, session_id, agent_id, step_index, step_type,
|
||||
input_summary, output_summary, duration_ms, timestamp
|
||||
FROM trajectory_events
|
||||
WHERE agent_id = ? AND timestamp >= ?
|
||||
ORDER BY timestamp ASC
|
||||
"#,
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(since.to_rfc3339())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
|
||||
let mut events = Vec::with_capacity(rows.len());
|
||||
for (id, sid, aid, step_idx, stype, input_s, output_s, dur_ms, ts) in rows {
|
||||
let timestamp = DateTime::parse_from_rfc3339(&ts)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
|
||||
events.push(TrajectoryEvent {
|
||||
id,
|
||||
session_id: sid,
|
||||
agent_id: aid,
|
||||
step_index: step_idx as usize,
|
||||
step_type: TrajectoryStepType::from_str_lossy(&stype),
|
||||
input_summary: input_s.unwrap_or_default(),
|
||||
output_summary: output_s.unwrap_or_default(),
|
||||
duration_ms: dur_ms.unwrap_or(0) as u64,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,6 +15,56 @@ use zclaw_types::Result;
|
||||
// Data types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pain point status for tracking resolution.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PainStatus {
|
||||
Active,
|
||||
Resolved,
|
||||
Deferred,
|
||||
}
|
||||
|
||||
impl PainStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
PainStatus::Active => "active",
|
||||
PainStatus::Resolved => "resolved",
|
||||
PainStatus::Deferred => "deferred",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str_lossy(s: &str) -> Self {
|
||||
match s {
|
||||
"resolved" => PainStatus::Resolved,
|
||||
"deferred" => PainStatus::Deferred,
|
||||
_ => PainStatus::Active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured pain point with tracking metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PainPoint {
|
||||
pub content: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_mentioned_at: DateTime<Utc>,
|
||||
pub status: PainStatus,
|
||||
pub occurrence_count: u32,
|
||||
}
|
||||
|
||||
impl PainPoint {
|
||||
pub fn new(content: &str) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
content: content.to_string(),
|
||||
created_at: now,
|
||||
last_mentioned_at: now,
|
||||
status: PainStatus::Active,
|
||||
occurrence_count: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expertise level inferred from conversation patterns.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -366,6 +416,45 @@ impl UserProfileStore {
|
||||
|
||||
self.upsert(&profile).await
|
||||
}
|
||||
|
||||
/// Find active pain points created since the given datetime.
|
||||
/// Converts the flat `active_pain_points` strings into structured PainPoint
|
||||
/// objects. Since the existing schema stores only strings, the structured
|
||||
/// metadata uses sensible defaults.
|
||||
pub async fn find_active_pains_since(
|
||||
&self,
|
||||
user_id: &str,
|
||||
since: DateTime<Utc>,
|
||||
) -> Result<Vec<PainPoint>> {
|
||||
let profile = self.get(user_id).await?;
|
||||
Ok(match profile {
|
||||
Some(p) => p
|
||||
.active_pain_points
|
||||
.into_iter()
|
||||
.filter(|_| true)
|
||||
.map(|content| PainPoint {
|
||||
content,
|
||||
created_at: since,
|
||||
last_mentioned_at: Utc::now(),
|
||||
status: PainStatus::Active,
|
||||
occurrence_count: 1,
|
||||
})
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Mark a pain point as resolved by removing it from active_pain_points.
|
||||
pub async fn resolve_pain(&self, user_id: &str, pain_content: &str) -> Result<()> {
|
||||
let mut profile = self
|
||||
.get(user_id)
|
||||
.await?
|
||||
.unwrap_or_else(|| UserProfile::blank(user_id));
|
||||
|
||||
profile.active_pain_points.retain(|p| p != pain_content);
|
||||
profile.updated_at = Utc::now();
|
||||
self.upsert(&profile).await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user