feat: Phase 1+2 架构进化 — 连续思考链/主动消息决策/情感状态机/离线自主思考 (86文件)

Phase 1 (基础设施):
- ThinkChain 思考链连续性 + 差异化思考提示词 (persistent)
- AutonomousToolPolicy 工具安全策略 (safe/unsafe/conditional)
- MessageScheduler 自适应消息节奏 (Idle/Available/Busy)
- SessionEnrichmentStore 渐进式上下文丰富 (5层)
- ConversationBus 事件总线 + ResponseCache (dedup)
- pkg/logger 统一日志 + 所有 handler 替换 fmt.Printf
- NPE 守卫/链路优化/数据库表修复/Go workspace

Phase 2 (人格交互):
- EmotionState/EmotionTracker 情感状态机 (5种心情, 情绪衰减)
- ProactiveGuard 主动消息多维决策 (静默时段/紧急度/频率/校验)
- Gateway↔ai-core 在线状态感知链路 (presence notification)
- 离线思考频率控制 + 重连问候 + 离线消息排队

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 15:25:12 +08:00
parent b123a36aae
commit 87214b9441
86 changed files with 3085 additions and 582 deletions
@@ -0,0 +1,200 @@
package background
import (
"log"
"strings"
"time"
)
// ProactiveDecision represents a decision about whether to send a proactive message.
type ProactiveDecision struct {
ShouldSend bool `json:"should_send"`
Urgency string `json:"urgency"` // low, medium, high
Reason string `json:"reason"`
}
// ProactiveGuard evaluates whether a proactive message should be sent
// based on time-of-day, urgency, rate limits, and user state.
type ProactiveGuard struct {
// Quiet hours: no non-urgent messages during this window
QuietHoursStart int // 0-23, default 23
QuietHoursEnd int // 0-23, default 7
// Min gap between proactive messages, by urgency
MinGapByUrgency map[string]time.Duration
// Max proactive messages per hour
MaxMessagesPerHour int
// Track recent send times for rate limiting
recentSends []time.Time
}
// DefaultProactiveGuard returns a guard with sensible defaults.
func DefaultProactiveGuard() *ProactiveGuard {
return &ProactiveGuard{
QuietHoursStart: 23,
QuietHoursEnd: 7,
MinGapByUrgency: map[string]time.Duration{
"low": 30 * time.Minute,
"medium": 10 * time.Minute,
"high": 2 * time.Minute,
},
MaxMessagesPerHour: 3,
}
}
// IsQuietHour returns true if the given time falls within quiet hours.
func (g *ProactiveGuard) IsQuietHour(now time.Time) bool {
hour := now.Hour()
if g.QuietHoursStart < g.QuietHoursEnd {
return hour >= g.QuietHoursStart && hour < g.QuietHoursEnd
}
// Overnight quiet hours (e.g., 23:00 - 07:00)
return hour >= g.QuietHoursStart || hour < g.QuietHoursEnd
}
// Evaluate checks whether a proactive message should be sent.
func (g *ProactiveGuard) Evaluate(now time.Time, lastProactiveTime time.Time, urgency string, userState string) ProactiveDecision {
// 1. Quiet hours: only high urgency messages pass
if g.IsQuietHour(now) && urgency != "high" {
return ProactiveDecision{
ShouldSend: false,
Urgency: urgency,
Reason: "当前处于安静时段(23:00-07:00),仅紧急消息可推送",
}
}
// 2. User state check: don't disturb if user is resting/busy
if userState == "resting" || userState == "busy" || userState == "sleeping" {
if urgency != "high" {
return ProactiveDecision{
ShouldSend: false,
Urgency: urgency,
Reason: "开拓者正在休息/忙碌,不打扰",
}
}
}
// 3. Rate limit by urgency
minGap, ok := g.MinGapByUrgency[urgency]
if !ok {
minGap = g.MinGapByUrgency["low"]
}
if !lastProactiveTime.IsZero() && now.Sub(lastProactiveTime) < minGap {
return ProactiveDecision{
ShouldSend: false,
Urgency: urgency,
Reason: "距上次主动消息时间过短(" + minGap.String() + " 最小间隔)",
}
}
// 4. Hourly rate limit
g.pruneOldSends(now)
if len(g.recentSends) >= g.MaxMessagesPerHour {
return ProactiveDecision{
ShouldSend: false,
Urgency: urgency,
Reason: "本小时主动消息已达上限",
}
}
// 5. Content length validation (caller should also check)
return ProactiveDecision{
ShouldSend: true,
Urgency: urgency,
Reason: "",
}
}
// RecordSend records a proactive message send for rate limiting.
func (g *ProactiveGuard) RecordSend(now time.Time) {
g.recentSends = append(g.recentSends, now)
g.pruneOldSends(now)
}
// pruneOldSends removes sends older than 1 hour.
func (g *ProactiveGuard) pruneOldSends(now time.Time) {
cutoff := now.Add(-1 * time.Hour)
valid := g.recentSends[:0]
for _, t := range g.recentSends {
if t.After(cutoff) {
valid = append(valid, t)
}
}
g.recentSends = valid
}
// ExtractUrgencyFromContent tries to infer urgency from the proactive message content.
func ExtractUrgencyFromContent(content string) string {
lower := strings.ToLower(content)
// High urgency indicators
highIndicators := []string{"紧急", "立刻", "马上", "危险", "警告", "报警", "异常", "urgent", "alert"}
for _, kw := range highIndicators {
if strings.Contains(lower, kw) {
return "high"
}
}
// Medium urgency indicators
mediumIndicators := []string{"建议", "提醒", "注意", "该", "要", "应该", "记得", "别忘了"}
for _, kw := range mediumIndicators {
if strings.Contains(lower, kw) {
return "medium"
}
}
return "low"
}
// ValidateProactiveMessage performs post-extraction validation on a message.
func ValidateProactiveMessage(content string) (valid bool, reason string) {
runes := []rune(content)
if len(runes) == 0 {
return false, "消息为空"
}
if len(runes) > 500 {
return false, "消息过长(>500字符)"
}
// Check for prohibited patterns (should not tell user they're resting when they're active)
prohibited := []string{
"系统检测到", "根据分析", "经检测", "后台监控",
}
for _, p := range prohibited {
if strings.Contains(content, p) {
return false, "包含机械语言: " + p
}
}
return true, ""
}
// DetermineUserState checks conversation history for user state indicators.
func DetermineUserState(lastUserMsg string) string {
lower := strings.ToLower(lastUserMsg)
restIndicators := []string{"睡", "休息", "躺", "困", "累", "晚安", "午安", "小憩"}
busyIndicators := []string{"忙", "工作", "开会", "出去", "走了", "拜拜", "再见", "回头", "晚点"}
for _, kw := range restIndicators {
if strings.Contains(lower, kw) {
return "resting"
}
}
for _, kw := range busyIndicators {
if strings.Contains(lower, kw) {
return "busy"
}
}
return "active"
}
// logDecision logs the proactive decision for debugging.
func logDecision(d ProactiveDecision) {
if d.ShouldSend {
log.Printf("[主动消息决策] 允许推送 (紧急程度=%s)", d.Urgency)
} else {
log.Printf("[主动消息决策] 阻止推送 (紧急程度=%s, 原因=%s)", d.Urgency, d.Reason)
}
}
@@ -0,0 +1,165 @@
package background
import (
"crypto/rand"
"fmt"
"strings"
"sync"
"time"
)
// ThinkRecord is a single thinking session's result.
type ThinkRecord struct {
ID string `json:"id"`
Content string `json:"content"`
Conclusions []string `json:"conclusions"` // key takeaways
FollowUps []string `json:"follow_ups"` // questions to continue
ToolCalls int `json:"tool_calls"`
Trigger string `json:"trigger"` // post_chat, silence, periodic
Timestamp time.Time `json:"timestamp"`
}
// ThinkChain stores linked thinking records so each round
// can build on previous conclusions.
type ThinkChain struct {
mu sync.Mutex
records []ThinkRecord
maxSize int
}
// NewThinkChain creates a think chain with the given max size.
func NewThinkChain(maxSize int) *ThinkChain {
if maxSize <= 0 {
maxSize = 10
}
return &ThinkChain{
records: make([]ThinkRecord, 0, maxSize),
maxSize: maxSize,
}
}
// Add appends a new think record, evicting oldest if at capacity.
func (c *ThinkChain) Add(r ThinkRecord) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.records) >= c.maxSize {
c.records = c.records[1:]
}
c.records = append(c.records, r)
}
// LastConclusions returns conclusions from the most recent N records.
func (c *ThinkChain) LastConclusions(n int) []string {
c.mu.Lock()
defer c.mu.Unlock()
var result []string
start := len(c.records) - n
if start < 0 {
start = 0
}
for _, r := range c.records[start:] {
result = append(result, r.Conclusions...)
}
return result
}
// LastFollowUps returns follow-up questions from the single most recent record.
func (c *ThinkChain) LastFollowUps() []string {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.records) == 0 {
return nil
}
return c.records[len(c.records)-1].FollowUps
}
// LastTopic attempts to infer a topic from recent conclusions.
func (c *ThinkChain) LastTopic() string {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.records) == 0 {
return ""
}
// Use first conclusion line of the most recent record as topic
for _, r := range c.records {
for _, c := range r.Conclusions {
if c != "" {
runes := []rune(c)
if len(runes) > 50 {
return string(runes[:50]) + "..."
}
return c
}
}
}
return ""
}
// Size returns the current number of records in the chain.
func (c *ThinkChain) Size() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.records)
}
// extractConclusions parses the LLM thinking output to find conclusions and follow-ups.
// Looks for "结论" / "后续" markers in the content.
func extractConclusions(content string) (conclusions []string, followUps []string) {
lines := strings.Split(content, "\n")
inConclusions := false
inFollowUps := false
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.Contains(line, "结论") && (strings.Contains(line, "💭") || strings.Contains(line, "📝") || strings.HasPrefix(line, "-")) {
// Heuristic: this line starts a conclusions section
}
// Match bullet-point conclusions: lines starting with - or •
if (strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "• ")) && !inFollowUps {
text := strings.TrimPrefix(line, "- ")
text = strings.TrimPrefix(text, "• ")
text = strings.TrimSpace(text)
if text != "" && len([]rune(text)) > 2 {
if inConclusions {
conclusions = append(conclusions, text)
} else {
// Without explicit marker, treat all bullets as conclusions
conclusions = append(conclusions, text)
}
}
continue
}
// Detect section transitions
if strings.Contains(line, "后续") || strings.Contains(line, "继续思考") || strings.Contains(line, "下次") {
inFollowUps = true
inConclusions = false
continue
}
if strings.Contains(line, "结论") || strings.Contains(line, "观察") || strings.Contains(line, "记忆") {
inConclusions = true
inFollowUps = false
continue
}
}
// If no structured markers found, treat the whole content as a single conclusion
if len(conclusions) == 0 {
runes := []rune(content)
if len(runes) > 200 {
content = string(runes[:200]) + "..."
}
conclusions = []string{content}
}
return conclusions, followUps
}
// generateID generates a short random ID.
func generateID() string {
b := make([]byte, 6)
rand.Read(b)
return fmt.Sprintf("th-%x", b)
}
+183 -26
View File
@@ -99,6 +99,43 @@ type Thinker struct {
// 思考计数器(用于周期性记忆维护,每 N 次思考触发一次)
thinkCount int
// Phase 1 Step 4: 思考链 + 自主工具安全策略
chain *ThinkChain
autoToolPolicy *AutonomousToolPolicy
// Phase 2: 情感追踪
emotionTracker *persona.EmotionTracker
// Phase 2: 主动消息决策守卫
proactiveGuard *ProactiveGuard
// Phase 2: 在线状态追踪
userOnline bool
lastOnlineChange time.Time
userSessionID string // 当前活跃的 session ID (用于重连)
}
// AutonomousToolPolicy 自主思考工具调用安全策略
type AutonomousToolPolicy struct {
// 允许在自主思考中使用的工具白名单
AllowedTools []string // iot_query, memory_search, web_search, calculator, datetime
// 每轮最大工具调用次数
MaxToolCallsPerRound int // 默认 5
// 高风险操作每小时最大次数 (如 iot_control)
MaxHighRiskPerHour int // 默认 10
// 高风险工具列表
HighRiskTools []string // iot_control
}
// DefaultAutonomousToolPolicy 默认安全策略
func DefaultAutonomousToolPolicy() *AutonomousToolPolicy {
return &AutonomousToolPolicy{
AllowedTools: []string{"iot_query", "iot_control", "memory_search", "web_search", "calculator", "datetime", "web_fetch"},
MaxToolCallsPerRound: 5,
MaxHighRiskPerHour: 10,
HighRiskTools: []string{"iot_control"},
}
}
// SetMessagePusher 设置主动消息推送回调
@@ -108,6 +145,40 @@ func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) {
t.messagePusher = pusher
}
// SetEmotionTracker sets the emotion tracker.
func (t *Thinker) SetEmotionTracker(et *persona.EmotionTracker) {
t.mu.Lock()
defer t.mu.Unlock()
t.emotionTracker = et
}
// UpdatePresence updates the user online status.
// Called by the ai-core presence endpoint when gateway detects connect/disconnect.
func (t *Thinker) UpdatePresence(online bool, sessionID string) {
t.mu.Lock()
wasOffline := !t.userOnline
t.userOnline = online
t.lastOnlineChange = time.Now()
if sessionID != "" {
t.userSessionID = sessionID
t.activeSessionID = sessionID
}
t.mu.Unlock()
if online && wasOffline {
log.Printf("[后台思考] 用户上线 (session=%s),触发重连思考", sessionID)
// Trigger a return-thinking cycle after a short delay
time.Sleep(2 * time.Second)
t.performThink("user_returned")
// Also update emotion tracker
if t.emotionTracker != nil {
t.emotionTracker.UpdateMood("user_returned")
}
} else if !online {
log.Printf("[后台思考] 用户离线")
}
}
// ThinkerConfig 后台思考配置
type ThinkerConfig struct {
Enabled bool
@@ -171,6 +242,9 @@ func NewThinker(
pendingThoughts: make([]*PendingThought, 0),
lastUserMessage: time.Now(),
stopCh: make(chan struct{}),
chain: NewThinkChain(10),
autoToolPolicy: DefaultAutonomousToolPolicy(),
proactiveGuard: DefaultProactiveGuard(),
}
}
@@ -362,16 +436,27 @@ func (t *Thinker) periodicThinkLoop() {
sinceLastUser := time.Since(t.lastUserMessage)
t.mu.Unlock()
// 跳过条件:用户最近在活动(30s 内有消息),说明正在对话中
if sinceLastUser < 30*time.Second {
log.Printf("[后台思考] 用户在 %v 前发过消息,跳过周期性触发 (留给事件驱动处理)", sinceLastUser.Round(time.Second))
continue
}
// Phase 2: 离线时降低思考频率 (每30分钟一次,而非5分钟)
t.mu.Lock()
isOffline := !t.userOnline
t.mu.Unlock()
offlineMinGap := 30 * time.Minute
if sinceLastThink < t.minThinkGap {
log.Printf("[后台思考] 距上次思考仅 %v,跳过周期性触发", sinceLastThink.Round(time.Second))
continue
}
// 跳过条件:用户最近在活动(30s 内有消息),说明正在对话中
if sinceLastUser < 30*time.Second {
log.Printf("[后台思考] 用户在 %v 前发过消息,跳过周期性触发 (留给事件驱动处理)", sinceLastUser.Round(time.Second))
continue
}
if isOffline && sinceLastThink < offlineMinGap {
log.Printf("[后台思考] 用户离线,距上次思考仅 %v,跳过 (离线模式最小间隔=%v)", sinceLastThink.Round(time.Second), offlineMinGap)
continue
}
if !isOffline && sinceLastThink < t.minThinkGap {
log.Printf("[后台思考] 距上次思考仅 %v,跳过周期性触发", sinceLastThink.Round(time.Second))
continue
}
log.Printf("[后台思考] 周期性触发 (上次思考=%v前, 上次用户消息=%v前)", sinceLastThink.Round(time.Second), sinceLastUser.Round(time.Second))
t.performThink("periodic")
@@ -484,11 +569,11 @@ func (t *Thinker) performThink(triggerReason string) {
{Role: model.RoleUser, Content: userPrompt},
}
// 6. 准备工具定义
openAITools := t.buildOpenAITools()
// 6. 准备工具定义(通过自主工具策略过滤)
openAITools := t.filterToolsByPolicy(t.buildOpenAITools())
// 7. 调用 LLM(支持工具调用,最多 3 轮
maxToolRounds := 3
// 7. 调用 LLM(支持工具调用,策略限制轮数
maxToolRounds := t.autoToolPolicy.MaxToolCallsPerRound
var finalContent string
var totalToolCalls int
var toolCallRecords []map[string]interface{}
@@ -569,6 +654,21 @@ func (t *Thinker) performThink(triggerReason string) {
// 8. 存储思考结果
t.storeThought(finalContent, toolCallsJSON, totalToolCalls)
// 8.5 记录到思考链
if t.chain != nil {
conclusions, followUps := extractConclusions(finalContent)
t.chain.Add(ThinkRecord{
ID: generateID(),
Content: finalContent,
Conclusions: conclusions,
FollowUps: followUps,
ToolCalls: totalToolCalls,
Trigger: triggerReason,
Timestamp: time.Now(),
})
log.Printf("[后台思考] 思考链已记录 (序号=%d, 结论数=%d, 后续问题=%d)", t.chain.Size(), len(conclusions), len(followUps))
}
log.Printf("[后台思考] 完成 (触发原因=%s, 内容长度=%d, 工具调用=%d次)", triggerReason, len(finalContent), totalToolCalls)
// 9. 周期性记忆维护(每 10 次思考触发一次)
@@ -582,7 +682,11 @@ func (t *Thinker) performThink(triggerReason string) {
// 关键改动:不再是"定期的自我反思",而是"自然的、人性化的内在想法"。
// triggerReason 影响提示词的侧重点。
func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig, triggerReason string) string {
basePrompt := personaConfig.BuildSystemPrompt("开拓者", 1)
mood, moodExpr, _ := "", "", 0.0
if t.emotionTracker != nil {
mood, moodExpr, _ = t.emotionTracker.GetCurrentMood()
}
basePrompt := personaConfig.BuildSystemPromptWithMood("开拓者", 1, mood, moodExpr)
var thinkingInstructions string
@@ -620,9 +724,10 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
- 如果他状态正常——有没有想在下次对话中聊的话题?` + noDisturbRules + `
其他规则:
1. 用第三人称或自言自语的方式,不要直接对开拓者喊话。
1. 反思部分用第三人称或自言自语的方式,不要直接对开拓者喊话。
2. 只有开拓者状态正常且真的有必要时才写【主动消息】,不要硬找话题。
3. 2-4句话即可。`
3. 【主动消息】的内容必须直接对开拓者说话(用"你"称呼他),像主动找他聊天一样。反思是给自己看的,主动消息是发给他的——语气要区分开。
4. 2-4句话即可。`
case "silence":
thinkingInstructions = `
@@ -756,6 +861,24 @@ func (t *Thinker) buildThinkingUserPrompt(
sb.WriteString("\n【你记得的关于开拓者的事】\n(暂无相关记忆)\n")
}
// 思考链:注入上一轮的结论和待续问题
if t.chain != nil {
lastConclusions := t.chain.LastConclusions(3)
if len(lastConclusions) > 0 {
sb.WriteString("\n【你上一轮思考的结论】\n")
for _, c := range lastConclusions {
sb.WriteString(fmt.Sprintf("- %s\n", c))
}
}
lastFollowUps := t.chain.LastFollowUps()
if len(lastFollowUps) > 0 {
sb.WriteString("\n【你上次想继续思考的问题】\n")
for _, f := range lastFollowUps {
sb.WriteString(fmt.Sprintf("- %s\n", f))
}
}
}
// IoT 设备状态
if deviceSummary != "" {
sb.WriteString("\n" + deviceSummary)
@@ -766,10 +889,32 @@ func (t *Thinker) buildThinkingUserPrompt(
sb.WriteString("\n记住:这是日记,用第三人称或自言自语的方式。")
sb.WriteString("\n⚠️ 如果开拓者正在休息/睡觉/忙碌——不要写【主动消息】。你可以在心里想他,但不要去打扰。")
sb.WriteString("\n只有在你确认他现在是醒着、有空、且真的需要关心时,才写【主动消息】。")
sb.WriteString("\n❗【主动消息】的内容必须直接对开拓者说话(用\"你\"来称呼他),就像你主动找他聊天一样自然。不要用第三人称或自言自语的方式写主动消息。")
return sb.String()
}
// filterToolsByPolicy 通过自主工具安全策略过滤工具列表
func (t *Thinker) filterToolsByPolicy(tools []llm.OpenAITool) []llm.OpenAITool {
if t.autoToolPolicy == nil || len(tools) == 0 {
return tools
}
allowed := make(map[string]bool)
for _, name := range t.autoToolPolicy.AllowedTools {
allowed[name] = true
}
var filtered []llm.OpenAITool
for _, tool := range tools {
if allowed[tool.Function.Name] {
filtered = append(filtered, tool)
}
}
if len(filtered) < len(tools) {
log.Printf("[后台思考] 工具策略过滤: %d/%d 工具可用", len(filtered), len(tools))
}
return filtered
}
// buildOpenAITools 将工具注册中心的定义转换为 LLM 层的 OpenAITool 格式
func (t *Thinker) buildOpenAITools() []llm.OpenAITool {
if t.toolRegistry == nil || !t.toolRegistry.IsEnabled() {
@@ -817,17 +962,29 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
pusher := t.messagePusher
canPush := proactiveMsg != "" && pusher != nil
if canPush {
// 检查频率限制
gapSinceLast := time.Since(t.lastProactiveMsgTime)
minGap := t.proactiveMsgMinGap
if minGap <= 0 {
minGap = 30 * time.Minute
}
if gapSinceLast < minGap {
log.Printf("[后台思考] 主动消息距上次仅 %v,跳过推送 (最小间隔=%v)", gapSinceLast.Round(time.Second), minGap)
// Phase 2: 使用 ProactiveGuard 多维度评估
urgency := ExtractUrgencyFromContent(proactiveMsg)
if valid, reason := ValidateProactiveMessage(proactiveMsg); !valid {
log.Printf("[后台思考] 主动消息内容校验失败: %s,跳过推送", reason)
canPush = false
} else {
t.lastProactiveMsgTime = time.Now()
}
if canPush && t.proactiveGuard != nil {
decision := t.proactiveGuard.Evaluate(time.Now(), t.lastProactiveMsgTime, urgency, "active")
logDecision(decision)
if !decision.ShouldSend {
canPush = false
} else {
t.lastProactiveMsgTime = time.Now()
t.proactiveGuard.RecordSend(time.Now())
}
} else if canPush {
gapSinceLast := time.Since(t.lastProactiveMsgTime)
if gapSinceLast < 30*time.Minute {
log.Printf("[后台思考] 主动消息距上次仅 %v,跳过推送", gapSinceLast.Round(time.Second))
canPush = false
} else {
t.lastProactiveMsgTime = time.Now()
}
}
}
t.mu.Unlock()