Files
Cyrene/backend/ai-core/internal/background/think_chain.go
T
AskaEth 87214b9441 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>
2026-05-23 15:25:12 +08:00

165 lines
4.2 KiB
Go

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)
}