fix(identity): 接通身份信号提取与持久化 — 对话中起名跨会话记忆
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
根因: 记忆提取管道(COMBINED_EXTRACTION_PROMPT)提取5种画像信号 但无身份信号(agent_name/user_name),不存在从对话到AgentConfig.name 或IdentityFiles的写回路径。 修复内容: - ProfileSignals 增加 agent_name/user_name 字段 - COMBINED_EXTRACTION_PROMPT 增加身份提取指令 - parse_profile_signals 解析新字段 + 回退推断 - GrowthIntegration 存储身份信号到 VikingStorage - post_conversation_hook 写回 soul.md + emit Tauri 事件 - streamStore 规则化检测 agent 名字并更新 AgentConfig.name - cold-start-mapper 新增 detectAgentNameSuggestion 链路: 对话→提取→VikingStorage→hook写回soul.md→事件→前端刷新
This commit is contained in:
@@ -253,6 +253,18 @@ impl MemoryExtractor {
|
||||
Ok(stored)
|
||||
}
|
||||
|
||||
/// Store a single pre-built MemoryEntry to VikingStorage
|
||||
pub async fn store_memory_entry(&self, entry: &crate::types::MemoryEntry) -> Result<()> {
|
||||
let viking = match &self.viking {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
tracing::warn!("[MemoryExtractor] No VikingAdapter configured");
|
||||
return Err(zclaw_types::ZclawError::Internal("No VikingAdapter".to_string()));
|
||||
}
|
||||
};
|
||||
viking.store(entry).await
|
||||
}
|
||||
|
||||
/// 统一提取:单次 LLM 调用同时产出 memories + experiences + profile_signals
|
||||
///
|
||||
/// 优先使用 `extract_with_prompt()` 进行单次调用;若 driver 不支持则
|
||||
@@ -481,6 +493,16 @@ fn parse_profile_signals(obj: &serde_json::Value) -> crate::types::ProfileSignal
|
||||
.and_then(|s| s.get("communication_style"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
agent_name: signals
|
||||
.and_then(|s| s.get("agent_name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
user_name: signals
|
||||
.and_then(|s| s.get("user_name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,6 +547,22 @@ fn infer_profile_signals_from_memories(
|
||||
signals.communication_style = Some(m.content.clone());
|
||||
}
|
||||
}
|
||||
// 身份信号回退: 从 preference 记忆中检测命名/称呼关键词
|
||||
let lower = m.content.to_lowercase();
|
||||
if lower.contains("叫你") || lower.contains("助手名字") || lower.contains("称呼") {
|
||||
if signals.agent_name.is_none() {
|
||||
// 尝试提取引号内的名字
|
||||
signals.agent_name = extract_quoted_name(&m.content)
|
||||
.or_else(|| extract_name_after_pattern(&lower, &m.content, "叫你"));
|
||||
}
|
||||
}
|
||||
if lower.contains("我叫") || lower.contains("我的名字") || lower.contains("用户名") {
|
||||
if signals.user_name.is_none() {
|
||||
signals.user_name = extract_name_after_pattern(&lower, &m.content, "我叫")
|
||||
.or_else(|| extract_name_after_pattern(&lower, &m.content, "我的名字是"))
|
||||
.or_else(|| extract_name_after_pattern(&lower, &m.content, "我叫"));
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::types::MemoryType::Knowledge => {
|
||||
if signals.recent_topic.is_none() && !m.keywords.is_empty() {
|
||||
@@ -547,6 +585,38 @@ fn infer_profile_signals_from_memories(
|
||||
signals
|
||||
}
|
||||
|
||||
/// 从引号中提取名字(如"以后叫你'小马'"→"小马")
|
||||
fn extract_quoted_name(text: &str) -> Option<String> {
|
||||
for delim in ['"', '\'', '「', '」', '『', '』'] {
|
||||
let mut parts = text.split(delim);
|
||||
parts.next(); // skip before first delimiter
|
||||
if let Some(name) = parts.next() {
|
||||
let trimmed = name.trim();
|
||||
if !trimmed.is_empty() && trimmed.chars().count() <= 20 {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 从指定模式后提取名字(如"叫你小马"→"小马")
|
||||
fn extract_name_after_pattern(lower: &str, original: &str, pattern: &str) -> Option<String> {
|
||||
if let Some(pos) = lower.find(pattern) {
|
||||
let after = &original[pos + pattern.len()..];
|
||||
// 取第一个词(中文或英文,最多10个字符)
|
||||
let name: String = after
|
||||
.chars()
|
||||
.take_while(|c| !c.is_whitespace() && !matches!(c, ','| '。' | '!' | '?' | ',' | '.' | '!' | '?'))
|
||||
.take(10)
|
||||
.collect();
|
||||
if !name.is_empty() {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Default extraction prompts for LLM
|
||||
pub mod prompts {
|
||||
use crate::types::MemoryType;
|
||||
@@ -594,7 +664,9 @@ pub mod prompts {
|
||||
"recent_topic": "最近讨论的主要话题(可选)",
|
||||
"pain_point": "用户当前痛点(可选)",
|
||||
"preferred_tool": "用户偏好的工具/技能(可选)",
|
||||
"communication_style": "沟通风格: concise|detailed|formal|casual(可选)"
|
||||
"communication_style": "沟通风格: concise|detailed|formal|casual(可选)",
|
||||
"agent_name": "用户给助手起的名称(可选,仅在用户明确命名时填写,如'以后叫你小马')",
|
||||
"user_name": "用户提到的自己的名字(可选,仅在用户明确自我介绍时填写,如'我叫张三')"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -604,8 +676,9 @@ pub mod prompts {
|
||||
1. **memories**: 提取用户偏好(沟通风格/格式/语言)、知识(事实/领域知识/经验教训)、使用经验(技能/工具使用模式和结果)
|
||||
2. **experiences**: 仅提取明确的"问题→解决"模式,要求有清晰的痛点和步骤,confidence >= 0.6
|
||||
3. **profile_signals**: 从对话中推断用户画像信息,只在有明确信号时填写,留空则不填
|
||||
4. 每个字段都要有实际内容,不确定的宁可省略
|
||||
5. 只返回 JSON,不要附加其他文本
|
||||
4. **identity**: 检测用户是否给助手命名(如"你叫X"/"以后叫你X"/"你的名字是X")或自我介绍(如"我叫X"/"我的名字是X"),填入 agent_name 或 user_name 字段
|
||||
5. 每个字段都要有实际内容,不确定的宁可省略
|
||||
6. 只返回 JSON,不要附加其他文本
|
||||
|
||||
对话内容:
|
||||
"#;
|
||||
|
||||
@@ -432,6 +432,10 @@ pub struct ProfileSignals {
|
||||
pub pain_point: Option<String>,
|
||||
pub preferred_tool: Option<String>,
|
||||
pub communication_style: Option<String>,
|
||||
/// 用户给助手起的名称(如"以后叫你小马")
|
||||
pub agent_name: Option<String>,
|
||||
/// 用户提到的自己的名字(如"我叫张三")
|
||||
pub user_name: Option<String>,
|
||||
}
|
||||
|
||||
impl ProfileSignals {
|
||||
@@ -442,6 +446,8 @@ impl ProfileSignals {
|
||||
|| self.pain_point.is_some()
|
||||
|| self.preferred_tool.is_some()
|
||||
|| self.communication_style.is_some()
|
||||
|| self.agent_name.is_some()
|
||||
|| self.user_name.is_some()
|
||||
}
|
||||
|
||||
/// 有效信号数量
|
||||
@@ -452,8 +458,15 @@ impl ProfileSignals {
|
||||
if self.pain_point.is_some() { count += 1; }
|
||||
if self.preferred_tool.is_some() { count += 1; }
|
||||
if self.communication_style.is_some() { count += 1; }
|
||||
if self.agent_name.is_some() { count += 1; }
|
||||
if self.user_name.is_some() { count += 1; }
|
||||
count
|
||||
}
|
||||
|
||||
/// 是否包含身份信号(agent_name 或 user_name)
|
||||
pub fn has_identity_signal(&self) -> bool {
|
||||
self.agent_name.is_some() || self.user_name.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// 进化事件
|
||||
@@ -674,8 +687,23 @@ mod tests {
|
||||
pain_point: None,
|
||||
preferred_tool: Some("researcher".to_string()),
|
||||
communication_style: Some("concise".to_string()),
|
||||
agent_name: None,
|
||||
user_name: None,
|
||||
};
|
||||
assert_eq!(signals.industry.as_deref(), Some("healthcare"));
|
||||
assert!(signals.pain_point.is_none());
|
||||
assert!(!signals.has_identity_signal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_signals_identity() {
|
||||
let signals = ProfileSignals {
|
||||
agent_name: Some("小马".to_string()),
|
||||
user_name: Some("张三".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(signals.has_identity_signal());
|
||||
assert_eq!(signals.signal_count(), 2);
|
||||
assert_eq!(signals.agent_name.as_deref(), Some("小马"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +440,39 @@ impl GrowthIntegration {
|
||||
}
|
||||
}
|
||||
|
||||
// Store identity signals as special memories for cross-session persistence
|
||||
if combined.profile_signals.has_identity_signal() {
|
||||
let agent_id_str = agent_id.to_string();
|
||||
if let Some(ref agent_name) = combined.profile_signals.agent_name {
|
||||
let entry = zclaw_growth::types::MemoryEntry::new(
|
||||
&agent_id_str,
|
||||
zclaw_growth::types::MemoryType::Preference,
|
||||
"identity",
|
||||
format!("助手的名字是{}", agent_name),
|
||||
).with_importance(8)
|
||||
.with_keywords(vec!["名字".to_string(), "称呼".to_string(), "identity".to_string(), agent_name.clone()]);
|
||||
if let Err(e) = self.extractor.store_memory_entry(&entry).await {
|
||||
tracing::warn!("[GrowthIntegration] Failed to store agent_name signal: {}", e);
|
||||
} else {
|
||||
tracing::info!("[GrowthIntegration] Stored agent_name '{}' for {}", agent_name, agent_id_str);
|
||||
}
|
||||
}
|
||||
if let Some(ref user_name) = combined.profile_signals.user_name {
|
||||
let entry = zclaw_growth::types::MemoryEntry::new(
|
||||
&agent_id_str,
|
||||
zclaw_growth::types::MemoryType::Preference,
|
||||
"identity",
|
||||
format!("用户的名字是{}", user_name),
|
||||
).with_importance(8)
|
||||
.with_keywords(vec!["名字".to_string(), "用户名".to_string(), "identity".to_string(), user_name.clone()]);
|
||||
if let Err(e) = self.extractor.store_memory_entry(&entry).await {
|
||||
tracing::warn!("[GrowthIntegration] Failed to store user_name signal: {}", e);
|
||||
} else {
|
||||
tracing::info!("[GrowthIntegration] Stored user_name '{}' for {}", user_name, agent_id_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert extracted memories to structured facts
|
||||
let facts: Vec<Fact> = combined
|
||||
.memories
|
||||
|
||||
Reference in New Issue
Block a user