358 lines
12 KiB
Python
358 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
update_profile.py — 从 journal 增量更新用户画像
|
||
=========================================
|
||
用法:
|
||
python3 update_profile.py # 增量更新
|
||
python3 update_profile.py --full # 全量重新分析
|
||
python3 update_profile.py --dry # 只输出,不写入
|
||
|
||
依赖:soulful_core.py(同目录)
|
||
"""
|
||
|
||
import json, os, re, sys
|
||
from datetime import datetime, timezone
|
||
from collections import Counter
|
||
|
||
HERMES = os.path.expanduser("~/.hermes")
|
||
D = HERMES + "/soulful"
|
||
JOURNAL_FILE = HERMES + "/daemon/journal.jsonl"
|
||
|
||
# 情绪词库
|
||
EMOTION_WORDS = {
|
||
"positive": ["开心", "高兴", "顺利", "棒", "完美", "感谢", "不错", "很好", "解决了", "成功了", "搞定", "舒服"],
|
||
"negative": ["累", "困", "压力", "焦虑", "沮丧", "失望", "崩溃", "烦", "头疼", "难", "搞不定", "失败", "错误"],
|
||
"focus": ["专注", "忙", "赶", "deadline", "紧急", "重要"],
|
||
}
|
||
|
||
# 目标关键词
|
||
GOAL_KEYWORDS = ["研究", "做", "改", "修复", "完成", "测试", "上线", "调研", "计划", "推进", "同步"]
|
||
|
||
# 沟通风格判断(基于句长)
|
||
def detect_communication_style(summaries: list) -> str:
|
||
if not summaries:
|
||
return "简洁"
|
||
total_len = sum(len(s) for s in summaries)
|
||
avg_len = total_len / len(summaries)
|
||
if avg_len < 30:
|
||
return "简洁直接"
|
||
elif avg_len < 60:
|
||
return "简洁"
|
||
else:
|
||
return "详细"
|
||
|
||
|
||
def load_journal(n: int = 100) -> list:
|
||
"""加载最近 N 条 journal 条目"""
|
||
if not os.path.exists(JOURNAL_FILE):
|
||
return []
|
||
with open(JOURNAL_FILE) as f:
|
||
lines = f.readlines()
|
||
entries = [json.loads(l) for l in lines[-n:] if l.strip()]
|
||
return entries[::-1] # 倒序,最新的在前
|
||
|
||
|
||
def extract_goals(entries: list) -> list:
|
||
"""从 journal 中提取当前目标(通过关键词+主题判断)"""
|
||
goals = []
|
||
recent_topics = []
|
||
for e in entries:
|
||
summary = e.get("summary", "")
|
||
event_type = e.get("type", "")
|
||
# 任务完成类型的事件通常是目标
|
||
if event_type in ("solve", "learn", "skill", "config"):
|
||
# 提取关键动作
|
||
if "织忆" in summary:
|
||
goals.append("织忆系统优化")
|
||
if "ColaOS" in summary or "page-agent" in summary:
|
||
goals.append("ColaOS研究")
|
||
if "skill" in summary.lower():
|
||
goals.append("技能管理")
|
||
if "daemon" in summary.lower():
|
||
goals.append("Daemon自动化")
|
||
recent_topics.append(summary[:50])
|
||
# 去重保留顺序
|
||
seen = set()
|
||
unique_goals = []
|
||
for g in goals:
|
||
if g not in seen:
|
||
seen.add(g)
|
||
unique_goals.append(g)
|
||
return unique_goals[:5]
|
||
|
||
|
||
def extract_emotional_state(entries: list) -> tuple:
|
||
"""从 journal 中判断情绪状态"""
|
||
pos_count = 0
|
||
neg_count = 0
|
||
notes = []
|
||
for e in entries[-20:]: # 只看最近20条
|
||
summary = e.get("summary", "")
|
||
for w in EMOTION_WORDS["positive"]:
|
||
if w in summary:
|
||
pos_count += 1
|
||
for w in EMOTION_WORDS["negative"]:
|
||
if w in summary:
|
||
neg_count += 1
|
||
notes.append(summary[:60])
|
||
if neg_count > pos_count * 2:
|
||
state = "tired"
|
||
elif pos_count > neg_count:
|
||
state = "good"
|
||
else:
|
||
state = "neutral"
|
||
return state, notes[-3:]
|
||
|
||
|
||
def detect_events_for_hearttraces(entries: list) -> list:
|
||
"""从 journal 中检测值得写入心迹的事件"""
|
||
events = []
|
||
for e in entries:
|
||
summary = e.get("summary", "")
|
||
# 检测重要共同经历
|
||
if "织忆" in summary and "修好" in summary:
|
||
events.append({
|
||
"content": "我们一起修好了织忆失忆问题(2026-06-25)",
|
||
"tags": ["成长", "织忆", "共同经历"],
|
||
"importance": 5,
|
||
"type": "moment"
|
||
})
|
||
if "ColaOS" in summary and "研究" in summary:
|
||
events.append({
|
||
"content": "牧尘开始研究 ColaOS,寻找织忆 Soulful 改造方向",
|
||
"tags": ["ColaOS", "研究"],
|
||
"importance": 4,
|
||
"type": "moment"
|
||
})
|
||
# 检测情绪表达
|
||
for w in EMOTION_WORDS["negative"]:
|
||
if w in summary:
|
||
events.append({
|
||
"content": f"牧尘表达了{w}:{summary[:40]}",
|
||
"tags": ["情绪", w],
|
||
"importance": 3,
|
||
"type": "signal"
|
||
})
|
||
break
|
||
return events
|
||
|
||
|
||
# ====== Phase 5: 自动牵挂识别 ======
|
||
|
||
import re as re_module
|
||
from datetime import timedelta
|
||
|
||
# 牵挂识别模式
|
||
CARE_PATTERNS = [
|
||
# 显式提醒型
|
||
(re_module.compile(r'记得\s*(.+)'), "记得"),
|
||
(re_module.compile(r'别忘了\s*(.+)'), "别忘"),
|
||
(re_module.compile(r'别忘\s*(.+)'), "别忘"),
|
||
(re_module.compile(r'回头\s*(.+)'), "回头"),
|
||
(re_module.compile(r'等有空\s*(.+)'), "等有空"),
|
||
(re_module.compile(r'待办[::]\s*(.+)'), "待办"),
|
||
(re_module.compile(r'TODO[::]\s*(.+)'), "TODO"),
|
||
(re_module.compile(r'\[ \]\s*(.+)'), "checkbox"),
|
||
# 计划型
|
||
(re_module.compile(r'(?:要|得|该)\s*(.{5,30}?)(?:的话|的话|的时候|时)?'), "计划"),
|
||
(re_module.compile(r'处理\s*(.+)'), "处理"),
|
||
(re_module.compile(r'做一下\s*(.+)'), "做一下"),
|
||
]
|
||
|
||
# 停用词(不作为牵挂)
|
||
CARE_STOPWORDS = {"什么", "为什么", "怎么", "如何", "多少", "哪个", "这里", "那里",
|
||
"今天", "明天", "昨天", "现在", "刚才", "刚才的"}
|
||
|
||
|
||
def extract_cares_from_journal(entries: list = None, dry: bool = True) -> list:
|
||
"""从 journal 中自动识别牵挂,自动添加到 CaresQueue
|
||
|
||
Args:
|
||
entries: journal 条目列表,None 则自动加载
|
||
dry: True=仅返回,不写入;False=写入 CaresQueue
|
||
"""
|
||
if entries is None:
|
||
entries = load_journal(100)
|
||
|
||
found_cares = []
|
||
|
||
for e in entries:
|
||
summary = e.get("summary", "")
|
||
details = e.get("details", "")
|
||
full_text = f"{summary} {details}"
|
||
|
||
for pattern, care_type in CARE_PATTERNS:
|
||
matches = pattern.findall(full_text)
|
||
for raw_text in matches:
|
||
# 清理
|
||
text = raw_text.strip().rstrip(".,,。!!??")
|
||
if len(text) < 4 or len(text) > 80:
|
||
continue
|
||
# 停用词过滤
|
||
if any(sw in text for sw in CARE_STOPWORDS):
|
||
continue
|
||
# 去除动作词前缀
|
||
text = re_module.sub(r'^(记得|别忘|要|得|该|处理|做)\s*', '', text)
|
||
|
||
found_cares.append({
|
||
"content": text,
|
||
"type": care_type,
|
||
"source": summary[:50],
|
||
})
|
||
|
||
# 去重(内容相似度匹配,公共前缀 > 10 字符相同视为重复)
|
||
unique = []
|
||
for c in found_cares:
|
||
is_dup = any(
|
||
c["content"][:15] == u["content"][:15]
|
||
for u in unique
|
||
)
|
||
if not is_dup:
|
||
unique.append(c)
|
||
|
||
if dry:
|
||
return unique
|
||
|
||
# 写入 CaresQueue
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
from soulful_core import CaresQueue
|
||
|
||
cq = CaresQueue()
|
||
existing = cq.pending()
|
||
added = []
|
||
|
||
# 已有牵挂的前15字符
|
||
existing_prefixes = {c["content"][:15] for c in existing}
|
||
|
||
for care in unique:
|
||
if care["content"][:15] in existing_prefixes:
|
||
continue
|
||
# follow_up_date = 3天后
|
||
follow_up = (datetime.now() + timedelta(days=3)).strftime("%Y-%m-%d")
|
||
care_id = cq.add(
|
||
content=care["content"],
|
||
context=f"[自动识别来自:{care['source']}]",
|
||
follow_up_date=follow_up,
|
||
tags=["自动识别", care["type"]]
|
||
)
|
||
added.append(care_id)
|
||
|
||
return added
|
||
|
||
|
||
def main():
|
||
dry = "--dry" in sys.argv
|
||
full = "--full" in sys.argv
|
||
|
||
print(f"[update_profile] {'[DRY]' if dry else ''} 开始更新用户画像...")
|
||
|
||
# 加载 journal
|
||
entries = load_journal(200 if full else 100)
|
||
print(f" 读取 journal: {len(entries)} 条")
|
||
if not entries:
|
||
print(" 无 journal 数据,退出")
|
||
return
|
||
|
||
# 提取各项
|
||
summaries = [e.get("summary", "") for e in entries]
|
||
style = detect_communication_style(summaries)
|
||
goals = extract_goals(entries)
|
||
emotion, emotion_notes = extract_emotional_state(entries)
|
||
|
||
print(f" 沟通风格: {style}")
|
||
print(f" 当前目标: {goals}")
|
||
print(f" 情绪状态: {emotion}")
|
||
|
||
if dry:
|
||
print(" [DRY] 不写入,仅输出")
|
||
return
|
||
|
||
# 更新画像
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
from soulful_core import UserProfile, HeartTraces
|
||
|
||
up = UserProfile()
|
||
|
||
# 更新沟通风格
|
||
if not up.get().get("communication_style") or full:
|
||
up.update({"communication_style": style})
|
||
|
||
# 更新目标(合并去重)
|
||
if goals:
|
||
existing_goals = up.get().get("current_goals", [])
|
||
merged = list(dict.fromkeys(existing_goals + goals))[:10]
|
||
up.update({"current_goals": merged})
|
||
|
||
# 更新情绪(只有更严重才降级,不轻易升级)
|
||
current = up.get().get("emotional_state", {}).get("current", "unknown")
|
||
if emotion == "tired" or current in ("tired",):
|
||
up.set_emotional_state(emotion, "; ".join(emotion_notes[:2]) if emotion_notes else "")
|
||
|
||
print(f" ✓ 画像已更新")
|
||
|
||
# 检测心迹事件
|
||
ht = HeartTraces()
|
||
current_count = ht.count()
|
||
events = detect_events_for_hearttraces(entries)
|
||
|
||
# 写入新心迹(只写入之前没有的)
|
||
for ev in events:
|
||
# 简单去重:检查最近 10 条是否有相同内容
|
||
recent = ht.recent(n=10)
|
||
duplicate = any(ev["content"][:30] in r.get("content", "") for r in recent)
|
||
if not duplicate:
|
||
ht.record(ev["content"], ev["tags"], ev["importance"], trace_type=ev["type"])
|
||
print(f" ✦ 心迹: {ev['content'][:50]}")
|
||
|
||
new_count = ht.count()
|
||
if new_count > current_count:
|
||
print(f" ✓ 心迹新增 {new_count - current_count} 条")
|
||
else:
|
||
print(f" ✓ 无新增心迹({new_count} 条)")
|
||
|
||
# ── P1: Soulful → 织忆 ──────────────────────────────────
|
||
profile = up.get()
|
||
style = profile.get("communication_style", style) # 用 fresh 检测值优先
|
||
goals = profile.get("current_goals", goals)
|
||
emotion = profile.get("emotional_state", {}).get("current", emotion)
|
||
|
||
# 变更检测:只有画像发生变化才写入织忆
|
||
last_hash_file = os.path.join(HERMES, "soulful", ".last_zhiyi_hash")
|
||
current_hash = f"{style}|{emotion}|{','.join(goals[:3])}"
|
||
last_hash = ""
|
||
if os.path.exists(last_hash_file):
|
||
with open(last_hash_file) as f:
|
||
last_hash = f.read().strip()
|
||
|
||
if current_hash != last_hash:
|
||
content = f"用户画像更新: 风格={style} 情绪={emotion} 目标={', '.join(goals[:3])}"
|
||
try:
|
||
zhiyi_write(content, "distilled")
|
||
with open(last_hash_file, 'w') as f:
|
||
f.write(current_hash)
|
||
print(f" ✓ 画像 → 织忆")
|
||
except Exception as e:
|
||
print(f" ⚠ 织忆写入失败: {e}")
|
||
else:
|
||
print(f" - 画像无变化,跳过织忆写入")
|
||
# ── P1 End ──────────────────────────────────────────────
|
||
|
||
|
||
def zhiyi_write(content: str, category: str = "distilled"):
|
||
"""写入织忆(http 直连)"""
|
||
import urllib.request, json
|
||
payload = json.dumps({
|
||
"content": content,
|
||
"category": category,
|
||
"agent_id": "a06"
|
||
}).encode()
|
||
req = urllib.request.Request(
|
||
"http://localhost:7821/api/v1/commit",
|
||
data=payload,
|
||
headers={"X-API-Key": "zhiyi-dev-key-2026", "Content-Type": "application/json"}
|
||
)
|
||
urllib.request.urlopen(req, timeout=5)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main() |