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,46 @@
package orchestrator
import "sync"
// EnrichmentData holds async sub-session results stored for the next user turn.
type EnrichmentData struct {
MemorySummary string
ThoughtOutline string
IoTSummary string
}
// SessionEnrichmentStore is a thread-safe per-session cache for async
// sub-session enrichment. Results from the current turn are stored here
// and injected at the start of the next turn's synthesis.
type SessionEnrichmentStore struct {
mu sync.RWMutex
data map[string]*EnrichmentData
}
// NewEnrichmentStore creates a new SessionEnrichmentStore.
func NewEnrichmentStore() *SessionEnrichmentStore {
return &SessionEnrichmentStore{
data: make(map[string]*EnrichmentData),
}
}
// Get returns stored enrichment for a session and clears it (one-shot consumption).
func (s *SessionEnrichmentStore) Get(sessionID string) *EnrichmentData {
s.mu.Lock()
defer s.mu.Unlock()
d, ok := s.data[sessionID]
if ok {
delete(s.data, sessionID)
}
return d
}
// Store saves enrichment for a session (called when sub-sessions complete).
func (s *SessionEnrichmentStore) Store(sessionID string, d *EnrichmentData) {
if d == nil {
return
}
s.mu.Lock()
s.data[sessionID] = d
s.mu.Unlock()
}
@@ -4,7 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"strings"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
@@ -31,7 +31,7 @@ func NewIntentAnalyzer(llmAdapter *llm.Adapter) *IntentAnalyzer {
func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*model.IntentResult, error) {
// 快速通道:简单问候/闲聊直接返回,跳过 LLM 调用
if a.isSimpleGreeting(userMessage) {
log.Printf("[intent] 快速通道: 检测到简单问候,跳过 LLM 分析")
logger.Printf("[intent] 快速通道: 检测到简单问候,跳过 LLM 分析")
result := &model.IntentResult{
Primary: "greeting",
NeedsMemory: false,
@@ -44,13 +44,13 @@ func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*mode
// 快速通道:强 IoT 关键词直接使用规则匹配,跳过 LLM 调用(节省 2-3s)
if a.isStrongIoTCommand(userMessage) {
log.Printf("[intent] 快速通道: 检测到 IoT 操控命令,跳过 LLM 分析")
logger.Printf("[intent] 快速通道: 检测到 IoT 操控命令,跳过 LLM 分析")
return a.keywordAnalyze(userMessage), nil
}
// 如果 LLM 不可用,直接使用关键词匹配
if !a.enabled || a.llmAdapter == nil {
log.Printf("[intent] LLM 不可用,使用关键词规则分析意图")
logger.Printf("[intent] LLM 不可用,使用关键词规则分析意图")
return a.keywordAnalyze(userMessage), nil
}
@@ -69,18 +69,18 @@ func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*mode
// 调用 LLM (同步)
resp, err := a.llmAdapter.Chat(ctx, messages)
if err != nil {
log.Printf("[intent] LLM 意图分析失败: %v,降级使用关键词规则", err)
logger.Printf("[intent] LLM 意图分析失败: %v,降级使用关键词规则", err)
return a.keywordAnalyze(userMessage), nil
}
// 解析 JSON 响应
intent, err := parseIntentResponse(resp.Content)
if err != nil {
log.Printf("[intent] 解析意图 JSON 失败: %v,降级使用关键词规则", err)
logger.Printf("[intent] 解析意图 JSON 失败: %v,降级使用关键词规则", err)
return a.keywordAnalyze(userMessage), nil
}
log.Printf("[intent] 意图分析完成: primary=%s, iot=%v, memory=%v, sentiment=%s",
logger.Printf("[intent] 意图分析完成: primary=%s, iot=%v, memory=%v, sentiment=%s",
intent.Primary, intent.NeedsIoT, intent.NeedsMemory, intent.Sentiment)
return intent, nil
@@ -0,0 +1,157 @@
package orchestrator
import (
"testing"
)
func TestIsSimpleGreeting(t *testing.T) {
a := &IntentAnalyzer{}
tests := []struct {
name string
input string
expected bool
}{
// Exact matches
{"你好 (exact)", "你好", true},
{"hello (exact)", "hello", true},
{"早上好 (exact)", "早上好", true},
{"晚安 (exact)", "晚安", true},
{"谢谢 (exact)", "谢谢", true},
{"在吗 (exact)", "在吗", true},
{"再见 (exact)", "再见", true},
{"单个嗯", "嗯", true},
// Short messages (<=4 chars, no complex keywords)
{"极短消息", "好的呀", true},
{"短闲聊", "哈哈", true},
{"OK", "ok", true},
// Short but with IoT/task keywords → not a greeting
{"短IoT关键词", "开灯", false},
{"短问题", "怎么", false},
{"短设备", "灯", false},
{"帮我", "帮我", false},
// Longer messages → not a greeting
{"正常对话", "今天天气真好呀", false},
{"长问候", "昔涟早上好呀,今天怎么样", false},
{"带问题", "你好,帮我开灯好吗", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := a.isSimpleGreeting(tt.input)
if got != tt.expected {
t.Errorf("isSimpleGreeting(%q) = %v, want %v", tt.input, got, tt.expected)
}
})
}
}
func TestIsStrongIoTCommand(t *testing.T) {
a := &IntentAnalyzer{}
tests := []struct {
name string
input string
expected bool
}{
// Control + device combinations → true
{"打开灯", "打开客厅灯", true},
{"关掉空调", "关掉卧室空调", true},
{"打开电视", "打开电视", true},
{"关闭窗帘", "关闭窗帘", true},
{"调到26度", "把空调调到26度", true},
{"设置温度", "设置空调温度", true},
{"关掉风扇", "关掉风扇", true},
// No device word → false
{"仅控制词", "打开", false},
{"仅设备词", "灯开了吗", false},
{"仅查询", "现在客厅灯是什么状态", false},
// Neither → false
{"普通对话", "你好呀", false},
{"闲聊", "今天天气不错", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := a.isStrongIoTCommand(tt.input)
if got != tt.expected {
t.Errorf("isStrongIoTCommand(%q) = %v, want %v", tt.input, got, tt.expected)
}
})
}
}
func TestKeywordAnalyze(t *testing.T) {
a := &IntentAnalyzer{}
tests := []struct {
name string
input string
wantPrimary string
wantNeedsIoT bool
wantSentiment string
}{
{"IoT命令", "打开客厅灯", "iot_control", true, "neutral"},
{"IoT查询", "现在灯是什么状态", "question", true, "neutral"},
{"I情感正面", "今天好开心呀", "chat", false, "positive"},
{"I情感负面", "我今天好累", "emotional", false, "negative"},
{"I提问", "怎么学习日语", "question", false, "neutral"},
{"I普通聊天", "今天天气真好", "chat", false, "neutral"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := a.keywordAnalyze(tt.input)
if got.Primary != tt.wantPrimary {
t.Errorf("keywordAnalyze(%q).Primary = %q, want %q", tt.input, got.Primary, tt.wantPrimary)
}
if got.NeedsIoT != tt.wantNeedsIoT {
t.Errorf("keywordAnalyze(%q).NeedsIoT = %v, want %v", tt.input, got.NeedsIoT, tt.wantNeedsIoT)
}
if got.Sentiment != tt.wantSentiment {
t.Errorf("keywordAnalyze(%q).Sentiment = %q, want %q", tt.input, got.Sentiment, tt.wantSentiment)
}
})
}
}
func TestParseIntentResponse(t *testing.T) {
tests := []struct {
name string
input string
want string // expected Primary
wantErr bool
}{
{"纯净JSON", `{"primary":"chat","needs_iot":false,"needs_memory":true,"sentiment":"positive","urgency":"low"}`, "chat", false},
{"Markdown包裹", "```json\n{\"primary\":\"iot_control\",\"needs_iot\":true,\"needs_memory\":true,\"sentiment\":\"neutral\",\"urgency\":\"high\"}\n```", "iot_control", false},
{"前后有空白", " \n{\"primary\":\"question\",\"needs_iot\":false,\"needs_memory\":true,\"sentiment\":\"neutral\",\"urgency\":\"medium\"}\n ", "question", false},
{"JSON前后有文字", "分析结果:{\"primary\":\"chat\",\"needs_iot\":false,\"needs_memory\":true,\"sentiment\":\"neutral\",\"urgency\":\"low\"},仅供参考", "chat", false},
{"默认值填充", `{"needs_iot":true}`, "chat", false}, // Primary 默认为 "chat"
{"无效JSON", "不是JSON", "", true},
{"空字符串", "", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseIntentResponse(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("parseIntentResponse(%q) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Errorf("parseIntentResponse(%q) unexpected error: %v", tt.input, err)
return
}
if got.Primary != tt.want {
t.Errorf("parseIntentResponse(%q).Primary = %q, want %q", tt.input, got.Primary, tt.want)
}
})
}
}
@@ -3,16 +3,20 @@ package orchestrator
import (
"context"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"strings"
"time"
"github.com/yourname/cyrene-ai/ai-core/internal/cache"
ctxbuild "github.com/yourname/cyrene-ai/ai-core/internal/context"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
"github.com/yourname/cyrene-ai/ai-core/internal/subsession"
"github.com/yourname/cyrene-ai/ai-core/internal/bus"
"github.com/yourname/cyrene-ai/ai-core/internal/scheduler"
)
// Orchestrator 对话编排器 v2.0
@@ -26,6 +30,44 @@ type Orchestrator struct {
synthesizer *Synthesizer
memoryRetriever *memory.Retriever
memoryExtractor *memory.Extractor
responseCache *cache.ResponseCache
eventBus bus.Bus
enrichmentStore *SessionEnrichmentStore
msgScheduler *scheduler.MessageScheduler
emotionTracker *persona.EmotionTracker
}
// SetResponseCache sets the response cache (optional, for Phase 0.2).
func (o *Orchestrator) SetResponseCache(c *cache.ResponseCache) {
o.responseCache = c
}
// SetBus sets the event bus (optional, for Phase 1).
func (o *Orchestrator) SetBus(b bus.Bus) {
o.eventBus = b
}
// SetEnrichmentStore sets the enrichment store (optional, for Phase 1 Step 2).
func (o *Orchestrator) SetEnrichmentStore(s *SessionEnrichmentStore) {
o.enrichmentStore = s
}
// SetMessageScheduler sets the message scheduler (optional, for Phase 1 Step 3).
func (o *Orchestrator) SetMessageScheduler(s *scheduler.MessageScheduler) {
o.msgScheduler = s
}
// SetEmotionTracker sets the emotion tracker (optional, for Phase 2).
func (o *Orchestrator) SetEmotionTracker(t *persona.EmotionTracker) {
o.emotionTracker = t
}
// getBus returns the bus or a nop fallback.
func (o *Orchestrator) getBus() bus.Bus {
if o.eventBus == nil {
return &bus.NopBus{}
}
return o.eventBus
}
// NewOrchestrator 创建编排器
@@ -84,15 +126,22 @@ func (o *Orchestrator) ProcessInput(
defer close(eventCh)
defer func() {
if r := recover(); r != nil {
log.Printf("[orchestrator] 编排器主循环 panic 恢复: %v", r)
logger.Printf("[orchestrator] 编排器主循环 panic 恢复: %v", r)
}
}()
// 0. 发布合成开始事件
o.getBus().Publish(bus.BusEvent{
Type: bus.EventSynthesisStarted,
SessionID: params.SessionID,
UserID: params.UserID,
})
// 1. 意图分析
startTime := time.Now()
intent, err := o.intentAnalyzer.Analyze(ctx, params.Message)
if err != nil || intent == nil {
log.Printf("[orchestrator] 意图分析失败: %v,使用默认值", err)
logger.Printf("[orchestrator] 意图分析失败: %v,使用默认值", err)
intent = &model.IntentResult{
Primary: "chat",
NeedsMemory: true,
@@ -100,7 +149,49 @@ func (o *Orchestrator) ProcessInput(
Urgency: "low",
}
}
log.Printf("[orchestrator] 意图分析耗时: %v, primary=%s", time.Since(startTime), intent.Primary)
logger.Printf("[orchestrator] 意图分析耗时: %v, primary=%s", time.Since(startTime), intent.Primary)
// 1.6 记录情感状态
if o.emotionTracker != nil {
o.emotionTracker.RecordSentiment(intent.Sentiment)
}
// 1.5 检查响应缓存
if o.responseCache != nil {
if cached, ok := o.responseCache.Get(params.Message); ok {
logger.Printf("[orchestrator] 缓存命中,跳过 LLM 调用")
fullContent := cached
eventCh <- model.StreamEvent{
Type: model.StreamDelta,
Delta: fullContent,
}
if reviewMessages := parseReviewMessages(fullContent); len(reviewMessages) > 0 {
reviewMessages = o.scheduleWithDelays(reviewMessages)
eventCh <- model.StreamEvent{
Type: model.StreamReview,
ReviewMessages: reviewMessages,
}
}
segmenter := llm.NewSegmenter()
var segments []model.Segment
for _, ch := range fullContent {
newSegs := segmenter.Feed(string(ch))
for _, s := range newSegs {
segments = append(segments, model.Segment{Index: s.Index, Text: s.Text})
}
}
if remaining := segmenter.Flush(); remaining != nil {
segments = append(segments, model.Segment{Index: remaining.Index, Text: remaining.Text})
}
if len(segments) > 0 {
eventCh <- model.StreamEvent{Type: model.StreamSegments, Segments: segments}
}
eventCh <- model.StreamEvent{Type: model.StreamDone}
o.contextBuilder.CacheMessage(params.SessionID, model.RoleAssistant, fullContent)
logger.Printf("[orchestrator] 缓存响应完成: len=%d", len([]rune(fullContent)))
return
}
}
// 2. 加载人格配置
personaConfig, err := o.personaLoader.Get("cyrene")
@@ -136,7 +227,7 @@ func (o *Orchestrator) ProcessInput(
var resultCh <-chan model.SubSessionResult
skipSubSessions := intent.Primary == "greeting" && !intent.NeedsMemory
if skipSubSessions {
log.Printf("[orchestrator] 快速通道: 简单问候(primary=%s),跳过子会话分派", intent.Primary)
logger.Printf("[orchestrator] 快速通道: 简单问候(primary=%s),跳过子会话分派", intent.Primary)
emptyCh := make(chan model.SubSessionResult)
close(emptyCh)
resultCh = emptyCh
@@ -144,11 +235,27 @@ func (o *Orchestrator) ProcessInput(
resultCh = o.subManager.Dispatch(subCtx, intent, params.Message, createParams)
}
// 4. 先构建基础综合参数(不含子会话结果),开始合成
history := o.contextBuilder.GetHistory(params.SessionID, 20)
systemPrompt := personaConfig.BuildSystemPrompt(userName, 1)
// 4. 加载上一轮异步完成的子会话富化结果
var prevEnrichment *EnrichmentData
if o.enrichmentStore != nil {
prevEnrichment = o.enrichmentStore.Get(params.SessionID)
if prevEnrichment != nil {
logger.Printf("[orchestrator] 加载上一轮富化结果: memory=%t thought=%t iot=%t",
prevEnrichment.MemorySummary != "",
prevEnrichment.ThoughtOutline != "",
prevEnrichment.IoTSummary != "")
}
}
// 构建初始综合参数(子会话结果)
// 5. 先构建基础综合参数(不含子会话结果),开始合成
history := o.contextBuilder.GetHistory(params.SessionID, 20)
mood, expr := "", ""
if o.emotionTracker != nil {
mood, expr, _ = o.emotionTracker.GetCurrentMood()
}
systemPrompt := personaConfig.BuildSystemPromptWithMood(userName, 1, mood, expr)
// 构建初始综合参数(注入上一轮富化结果)
synthParams := SynthesizeParams{
UserID: params.UserID,
SessionID: params.SessionID,
@@ -158,75 +265,51 @@ func (o *Orchestrator) ProcessInput(
DialogHistory: history,
Mode: params.Mode,
}
// 非阻塞收集子会话结果:使用 goroutine + channel
// 主流程先开始 LLM 合成,子会话结果到达后再逐步注入
type enrichedParams struct {
memorySummary string
thoughtOutline string
iotSummary string
if prevEnrichment != nil {
synthParams.MemorySummary = prevEnrichment.MemorySummary
synthParams.ThoughtOutline = prevEnrichment.ThoughtOutline
synthParams.IoTSummary = prevEnrichment.IoTSummary
}
enrichedCh := make(chan enrichedParams, 1)
// 异步收集子会话结果,存入 enrichmentStore 供下一轮使用
go func() {
defer close(enrichedCh)
var enriched enrichedParams
var enriched EnrichmentData
for result := range resultCh {
if result.Error != "" {
log.Printf("[orchestrator] 子会话 %s 出错: %s", result.Type, result.Error)
logger.Printf("[orchestrator] 子会话 %s 出错: %s", result.Type, result.Error)
continue
}
switch result.Type {
case model.SubSessionMemory:
enriched.memorySummary = result.Summary
enriched.MemorySummary = result.Summary
if result.Details != "" {
enriched.memorySummary += "\n" + result.Details
enriched.MemorySummary += "\n" + result.Details
}
log.Printf("[orchestrator] 记忆子会话完成: %s", result.Summary)
logger.Printf("[orchestrator] 记忆子会话完成: %s", result.Summary)
case model.SubSessionGeneral:
enriched.thoughtOutline = result.Summary
enriched.ThoughtOutline = result.Summary
if result.Details != "" {
enriched.thoughtOutline += "\n" + result.Details
enriched.ThoughtOutline += "\n" + result.Details
}
log.Printf("[orchestrator] 通用对话子会话完成: %s", result.Summary)
logger.Printf("[orchestrator] 通用对话子会话完成: %s", result.Summary)
case model.SubSessionIoT:
enriched.iotSummary = result.Summary
log.Printf("[orchestrator] IoT 子会话完成: %s", result.Summary)
enriched.IoTSummary = result.Summary
logger.Printf("[orchestrator] IoT 子会话完成: %s", result.Summary)
}
}
enrichedCh <- enriched
log.Printf("[orchestrator] 子会话全部完成: 结果已收集")
}()
// 注入已到达的子会话结果(如果在合成开始前就有结果到达)
// 启动合成(可能此时还没有子会话结果,先带着空上下文开始)
select {
case enriched := <-enrichedCh:
synthParams.MemorySummary = enriched.memorySummary
synthParams.ThoughtOutline = enriched.thoughtOutline
synthParams.IoTSummary = enriched.iotSummary
default:
// 子会话结果还没完成,先带着空上下文开始合成
// 大部分情况下子会话结果会在 LLM 调用前完成
// 等待一小段时间让快速子会话(如 IoT)完成
timeout := time.After(200 * time.Millisecond)
select {
case enriched := <-enrichedCh:
synthParams.MemorySummary = enriched.memorySummary
synthParams.ThoughtOutline = enriched.thoughtOutline
synthParams.IoTSummary = enriched.iotSummary
case <-timeout:
log.Printf("[orchestrator] 子会话超时等待,以当前上下文开始合成")
if o.enrichmentStore != nil {
o.enrichmentStore.Store(params.SessionID, &enriched)
logger.Printf("[orchestrator] 子会话全部完成,富化结果已存入下一轮")
}
}
}()
// 5. 调用 Synthesizer 流式生成最终回复
chunkCh, err := o.synthesizer.Synthesize(ctx, synthParams)
if err != nil {
log.Printf("[orchestrator] 综合器启动失败: %v", err)
logger.Printf("[orchestrator] 综合器启动失败: %v", err)
eventCh <- model.StreamEvent{
Type: model.StreamError,
Error: fmt.Errorf("生成回复失败: %w", err),
@@ -241,7 +324,7 @@ func (o *Orchestrator) ProcessInput(
for chunk := range chunkCh {
if chunk.Error != nil {
log.Printf("[orchestrator] 流式错误: %v", chunk.Error)
logger.Printf("[orchestrator] 流式错误: %v", chunk.Error)
eventCh <- model.StreamEvent{
Type: model.StreamError,
Error: chunk.Error,
@@ -282,12 +365,20 @@ func (o *Orchestrator) ProcessInput(
if fullContent != "" {
reviewMessages := parseReviewMessages(fullContent)
if len(reviewMessages) > 0 {
// 通过 MessageScheduler 计算每条消息的发送延迟
reviewMessages = o.scheduleWithDelays(reviewMessages)
eventCh <- model.StreamEvent{
Type: model.StreamReview,
ReviewMessages: reviewMessages,
}
log.Printf("[orchestrator] 审查完成: %d 条带类型消息", len(reviewMessages))
logger.Printf("[orchestrator] 审查完成: %d 条带类型消息", len(reviewMessages))
}
o.getBus().Publish(bus.BusEvent{
Type: bus.EventReviewReady,
SessionID: params.SessionID,
UserID: params.UserID,
Payload: bus.ReviewPayload{Messages: reviewMessages},
})
}
// 8. 发送断句信息
@@ -303,9 +394,18 @@ func (o *Orchestrator) ProcessInput(
Type: model.StreamDone,
}
o.getBus().Publish(bus.BusEvent{
Type: bus.EventSynthesisDone,
SessionID: params.SessionID,
UserID: params.UserID,
})
// 10. 后处理:缓存回复
if fullContent != "" {
o.contextBuilder.CacheMessage(params.SessionID, model.RoleAssistant, fullContent)
if o.responseCache != nil {
o.responseCache.Set(params.Message, fullContent)
}
}
// 11. 异步提取记忆
@@ -319,13 +419,40 @@ func (o *Orchestrator) ProcessInput(
)
}
log.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, time=%v",
logger.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, time=%v",
intent.Primary, len([]rune(fullContent)), time.Since(startTime))
}()
return eventCh, nil
}
// scheduleWithDelays 通过 MessageScheduler 为审查消息分配发送延迟
func (o *Orchestrator) scheduleWithDelays(messages []model.ReviewMessage) []model.ReviewMessage {
if o.msgScheduler == nil || len(messages) <= 1 {
return messages
}
scheduled := make([]scheduler.ScheduledMessage, len(messages))
for i, m := range messages {
displayType := scheduler.DisplayChat
if m.Type == model.ReviewMessageAction {
displayType = scheduler.DisplayAction
}
scheduled[i] = scheduler.ScheduledMessage{
Type: displayType,
Content: m.Content,
}
}
scheduled = o.msgScheduler.Schedule(scheduled)
for i := range messages {
messages[i].DelayMs = int(scheduled[i].Delay.Milliseconds())
}
return messages
}
// parseReviewMessages 解析完整回复文本,拆分为带类型的消息
// 用于审查子会话的轻量版本(内联到 orchestrator 以减少一次子会话调度开销)
func parseReviewMessages(text string) []model.ReviewMessage {
@@ -0,0 +1,211 @@
package orchestrator
import (
"testing"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
)
func TestParseReviewMessages(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantType []model.ReviewMessageType // type of each message in order
}{
{"纯聊天无括号", "叶酱,客厅灯早就开着啦", 1, []model.ReviewMessageType{model.ReviewMessageChat}},
{"纯动作括号", "(歪着头看你)", 1, []model.ReviewMessageType{model.ReviewMessageAction}},
{"中文括号动作", "(歪着头看你)", 1, []model.ReviewMessageType{model.ReviewMessageAction}},
{"动作+聊天", "(歪着头看你) 叶酱,客厅灯早就开着啦♪", 2, []model.ReviewMessageType{model.ReviewMessageAction, model.ReviewMessageChat}},
{"聊天+动作", "我帮你关掉了哦 (轻轻按下遥控器)", 2, []model.ReviewMessageType{model.ReviewMessageChat, model.ReviewMessageAction}},
{"只有括号但无内容", "", 0, nil},
{"空括号", "()", 1, []model.ReviewMessageType{model.ReviewMessageChat}}, // fallback to chat for unparseable bracket
{"多段落", "第一段内容\n\n第二段内容", 2, []model.ReviewMessageType{model.ReviewMessageChat, model.ReviewMessageChat}},
{"动作+多段聊天", "(歪头) 第一段\n\n第二段内容", 3, []model.ReviewMessageType{model.ReviewMessageAction, model.ReviewMessageChat, model.ReviewMessageChat}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseReviewMessages(tt.input)
if tt.wantLen == 0 && len(got) == 0 {
return
}
if len(got) != tt.wantLen {
t.Errorf("parseReviewMessages(%q) len = %d, want %d\ngot: %+v", tt.input, len(got), tt.wantLen, got)
return
}
for i, m := range got {
if i < len(tt.wantType) && m.Type != tt.wantType[i] {
t.Errorf("parseReviewMessages(%q)[%d].Type = %q, want %q", tt.input, i, m.Type, tt.wantType[i])
}
}
})
}
}
func TestSplitChatByLines(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
}{
{"单行", "这是单行消息", 1},
{"双换行分割", "第一段\n\n第二段", 2},
{"三段", "第一段\n\n第二段\n\n第三段", 3},
{"只有空白行", "\n\n\n\n", 0},
{"混合空白", " 第一段 \n\n 第二段 ", 2},
{"单换行不分割", "第一行\n第二行", 1}, // 单\n不分割
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := splitChatByLines(model.ReviewMessageChat, tt.input)
if len(got) != tt.wantLen {
t.Errorf("splitChatByLines(%q) len = %d, want %d\ngot: %+v", tt.input, len(got), tt.wantLen, got)
}
})
}
}
func TestSplitReviewLongMessage(t *testing.T) {
tests := []struct {
name string
input string
wantMax int // max messages expected (1 for short)
}{
{"短消息不拆分", "这是一条短消息", 1},
{"刚好80字", "这是一条刚好八十字的消息测试一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十", 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := splitReviewLongMessage(model.ReviewMessageChat, tt.input)
if len(got) > tt.wantMax {
t.Errorf("splitReviewLongMessage(%q) len = %d, want <= %d", tt.input, len(got), tt.wantMax)
}
for _, m := range got {
if m.Type != model.ReviewMessageChat {
t.Errorf("splitReviewLongMessage msg type = %q, want chat", m.Type)
}
}
})
}
}
func TestSplitLongText(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
}{
{"短文本不分割", "短文本", 80},
{"空文本", "", 80},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runes := []rune(tt.input)
got := splitLongText(model.ReviewMessageChat, runes, tt.maxLen)
if tt.input == "" && len(got) == 0 {
return
}
if len(got) == 0 {
t.Errorf("splitLongText returned empty for non-empty input")
}
// Verify all chunks preserve type and aren't empty
for i, m := range got {
if m.Type != model.ReviewMessageChat {
t.Errorf("splitLongText[%d].Type = %q, want chat", i, m.Type)
}
if m.Content == "" && tt.input != "" {
t.Errorf("splitLongText[%d].Content is empty", i)
}
}
})
}
}
// TestSplitLongTextLong verifies that a long text is split at sentence boundaries (80-rune max)
func TestSplitLongTextLong(t *testing.T) {
// Build a string > 80 runes with sentence breaks
input := "今天天气真好呀。" +
"我们去公园散步吧,然后可以去喝杯咖啡。" +
"你觉得怎么样呢?顺便可以叫上朋友一起去。" +
"人多热闹一些呢。" +
"今天的阳光也特别好,适合出去走走,呼吸新鲜空气对身体有好处。"
runes := []rune(input)
maxLen := 80
if len(runes) <= maxLen {
t.Skip("test requires input > 80 runes")
}
got := splitLongText(model.ReviewMessageChat, runes, maxLen)
if len(got) < 2 {
t.Errorf("splitLongText on >80 rune text should produce >= 2 chunks, got %d", len(got))
}
// Verify each chunk is <= maxLen
for i, m := range got {
if len([]rune(m.Content)) > maxLen {
t.Errorf("chunk[%d] has %d runes, exceeds max %d", i, len([]rune(m.Content)), maxLen)
}
}
}
// TestParseReviewMessagesEdgeCases covers edge inputs
func TestParseReviewMessagesEdgeCases(t *testing.T) {
// Multiple action brackets
result := parseReviewMessages("(笑) 这句话很有意思呢 (摇摇头) 不过我理解你的意思")
if len(result) < 3 {
t.Errorf("Expected at least 3 messages, got %d: %+v", len(result), result)
}
// Only action brackets
result = parseReviewMessages("(点头)")
if len(result) != 1 || result[0].Type != model.ReviewMessageAction {
t.Errorf("Expected 1 action message, got: %+v", result)
}
// Unicode content
result = parseReviewMessages("(微笑)叶酱,今天好开心呀♪ 一起加油吧✨")
if len(result) < 2 {
t.Errorf("Expected at least 2 messages, got %d: %+v", len(result), result)
}
}
// TestIsSimpleGreetingEdgeCases covers whitespace and casing
func TestIsSimpleGreetingEdgeCases(t *testing.T) {
a := &IntentAnalyzer{}
// Whitespace handling
if !a.isSimpleGreeting(" 你好 ") {
t.Error("isSimpleGreeting with surrounding spaces should match")
}
// Case insensitivity
if !a.isSimpleGreeting("Hello") {
t.Error("isSimpleGreeting should be case-insensitive for English")
}
// Very long message is not a greeting
if a.isSimpleGreeting("昔涟你好呀,今天我想跟你说一件很重要很重要的事情") {
t.Error("Long message should not be detected as simple greeting")
}
}
// TestIsStrongIoTCommandEdgeCases covers edge cases
func TestIsStrongIoTCommandEdgeCases(t *testing.T) {
a := &IntentAnalyzer{}
// "开" within non-IoT word should not match alone
if a.isStrongIoTCommand("开心的一天") {
t.Error("'开心' should not trigger IoT command")
}
// Combined with device word
if !a.isStrongIoTCommand("帮我把卧室空调打开可以吗") {
t.Error("'打开'+'空调' should trigger IoT command")
}
// Only device word
if a.isStrongIoTCommand("风扇声音好大") {
t.Error("'风扇' alone should not trigger IoT command")
}
}
@@ -3,7 +3,7 @@ package orchestrator
import (
"context"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"strings"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
@@ -42,7 +42,7 @@ type SynthesizeParams struct {
func (s *Synthesizer) Synthesize(ctx context.Context, params SynthesizeParams) (<-chan llm.StreamChunk, error) {
messages := s.buildSynthesizeMessages(params)
log.Printf("[synthesizer] 开始综合 (上下文 %d 条消息)", len(messages))
logger.Printf("[synthesizer] 开始综合 (上下文 %d 条消息)", len(messages))
// 流式调用 LLM
return s.llmAdapter.ChatStream(ctx, messages)
@@ -118,7 +118,7 @@ func AggregateResults(results []model.SubSessionResult) *AggregatedContext {
for _, r := range results {
if r.Error != "" {
log.Printf("[aggregate] 子会话 %s 出错: %s", r.Type, r.Error)
logger.Printf("[aggregate] 子会话 %s 出错: %s", r.Type, r.Error)
continue
}