fix: IoT多设备支持 + Review Pipeline审查消息 + 意图分析快速通道优化
- IoT Provider: 重写Execute()支持多设备命令批量执行,修复persona路径 - Intent Analyzer: 新增isStrongIoTCommand快速通道,跳过LLM分析节省2-3s - Orchestrator: parseReviewMessages()内联审查 + 快速通道扩展(chat/greeting跳过子会话) - Gateway: SSE review_messages解析→WebSocket结构化消息转发(action/chat) - Persona: 对话风格注入action格式指令(括号包裹动作描述) - Frontend: sessionStore历史消息msgType映射 - 新增E2E测试脚本 + 调试标准文档 + 第4轮修复报告 E2E验证: IoT设备操控✅ Review消息拆分✅ 快速通道✅ 响应时间~3.4s Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -159,7 +159,7 @@ func main() {
|
||||
subManager.Register(subsession.NewMemoryProvider(memRetriever))
|
||||
}
|
||||
if iotClient != nil {
|
||||
subManager.Register(subsession.NewIoTProvider(iotClient))
|
||||
subManager.Register(subsession.NewIoTProvider(iotClient, personaDir))
|
||||
}
|
||||
subManager.Register(subsession.NewReviewProvider())
|
||||
log.Printf("子会话管理器已就绪: %d 个提供者 (%v)", len(subManager.ListProviders()), subManager.ListProviders())
|
||||
@@ -429,6 +429,15 @@ func handleChat(
|
||||
fmt.Fprintf(w, "data: %s\n\n", segData)
|
||||
flusher.Flush()
|
||||
|
||||
case model.StreamReview:
|
||||
// 发送审查后的结构化消息(动作消息 + 聊天消息)
|
||||
reviewData, _ := json.Marshal(map[string]interface{}{
|
||||
"message_id": messageID,
|
||||
"review_messages": event.ReviewMessages,
|
||||
})
|
||||
fmt.Fprintf(w, "data: %s\n\n", reviewData)
|
||||
flusher.Flush()
|
||||
|
||||
case model.StreamDone:
|
||||
// 下发结束标记
|
||||
endData, _ := json.Marshal(map[string]interface{}{
|
||||
|
||||
@@ -42,6 +42,12 @@ func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*mode
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 快速通道:强 IoT 关键词直接使用规则匹配,跳过 LLM 调用(节省 2-3s)
|
||||
if a.isStrongIoTCommand(userMessage) {
|
||||
log.Printf("[intent] 快速通道: 检测到 IoT 操控命令,跳过 LLM 分析")
|
||||
return a.keywordAnalyze(userMessage), nil
|
||||
}
|
||||
|
||||
// 如果 LLM 不可用,直接使用关键词匹配
|
||||
if !a.enabled || a.llmAdapter == nil {
|
||||
log.Printf("[intent] LLM 不可用,使用关键词规则分析意图")
|
||||
@@ -119,6 +125,33 @@ func (a *IntentAnalyzer) isSimpleGreeting(userMessage string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isStrongIoTCommand 检测是否为明确的 IoT 操控命令,可直接跳过 LLM 意图分析
|
||||
func (a *IntentAnalyzer) isStrongIoTCommand(userMessage string) bool {
|
||||
msgLower := strings.TrimSpace(strings.ToLower(userMessage))
|
||||
|
||||
// 控制类关键词 + 设备类关键词组合出现,即可判断为 IoT 命令
|
||||
controlWords := []string{"打开", "关闭", "调到", "设置", "开关", "调节", "调高", "调低", "开一下", "关一下"}
|
||||
deviceWords := []string{"灯", "空调", "窗帘", "电视", "风扇", "加湿器", "插座", "门锁", "传感器"}
|
||||
|
||||
hasControl := false
|
||||
for _, w := range controlWords {
|
||||
if strings.Contains(msgLower, w) {
|
||||
hasControl = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
hasDevice := false
|
||||
for _, w := range deviceWords {
|
||||
if strings.Contains(msgLower, w) {
|
||||
hasDevice = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return hasControl && hasDevice
|
||||
}
|
||||
|
||||
// keywordAnalyze 基于关键词的意图分析(降级方案)
|
||||
func (a *IntentAnalyzer) keywordAnalyze(userMessage string) *model.IntentResult {
|
||||
result := &model.IntentResult{
|
||||
|
||||
@@ -133,9 +133,10 @@ func (o *Orchestrator) ProcessInput(
|
||||
|
||||
// 对于 simple greeting,跳过子会话分派,直接合成回复
|
||||
var resultCh <-chan model.SubSessionResult
|
||||
skipSubSessions := intent.Primary == "greeting"
|
||||
skipSubSessions := intent.Primary == "greeting" ||
|
||||
(intent.Primary == "chat" && !intent.NeedsIoT && !intent.NeedsMemory)
|
||||
if skipSubSessions {
|
||||
log.Printf("[orchestrator] 快速通道: 简单问候,跳过子会话分派")
|
||||
log.Printf("[orchestrator] 快速通道: 简单消息(primary=%s),跳过子会话分派", intent.Primary)
|
||||
// 创建一个已关闭的空通道
|
||||
emptyCh := make(chan model.SubSessionResult)
|
||||
close(emptyCh)
|
||||
@@ -212,7 +213,7 @@ func (o *Orchestrator) ProcessInput(
|
||||
// 子会话结果还没完成,先带着空上下文开始合成
|
||||
// 大部分情况下子会话结果会在 LLM 调用前完成
|
||||
// 等待一小段时间让快速子会话(如 IoT)完成
|
||||
timeout := time.After(500 * time.Millisecond)
|
||||
timeout := time.After(200 * time.Millisecond)
|
||||
select {
|
||||
case enriched := <-enrichedCh:
|
||||
synthParams.MemorySummary = enriched.memorySummary
|
||||
|
||||
@@ -264,6 +264,7 @@ func (pc *PersonaConfig) buildConversationStyle() string {
|
||||
}
|
||||
sb.WriteString("- 像 LINE 聊天一样,随意、亲切、有温度\n")
|
||||
sb.WriteString("- 偶尔可以用语气词开头:\"嗯...\"、\"啊\"、\"诶\"\n")
|
||||
sb.WriteString("- 执行操作时(开关设备、查询状态等),用括号包裹动作描述,后面跟自然对话。例如:\"(帮你把客厅灯关掉啦) 嗯,已经关好了~\"\n")
|
||||
|
||||
if len(cs.SentenceEnders) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- 句尾可以带这些语气符:%s\n", strings.Join(cs.SentenceEnders, " ")))
|
||||
|
||||
@@ -2,7 +2,6 @@ package subsession
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
@@ -25,13 +24,15 @@ type IoTDeviceProvider interface {
|
||||
// IoTProvider IoT 控制子会话提供者
|
||||
// 职责:处理 IoT 设备查询和控制请求
|
||||
type IoTProvider struct {
|
||||
iotClient IoTDeviceProvider
|
||||
iotClient IoTDeviceProvider
|
||||
personaDir string
|
||||
}
|
||||
|
||||
// NewIoTProvider 创建 IoT 控制子会话提供者
|
||||
func NewIoTProvider(iotClient IoTDeviceProvider) *IoTProvider {
|
||||
func NewIoTProvider(iotClient IoTDeviceProvider, personaDir string) *IoTProvider {
|
||||
return &IoTProvider{
|
||||
iotClient: iotClient,
|
||||
iotClient: iotClient,
|
||||
personaDir: personaDir,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +115,11 @@ func (p *IoTProvider) CreateContext(ctx context.Context, params CreateContextPar
|
||||
|
||||
// 加载人格配置
|
||||
trueName := "昔涟"
|
||||
loader, err := persona.NewLoader("")
|
||||
personaPath := p.personaDir
|
||||
if personaPath == "" {
|
||||
personaPath = "./internal/persona"
|
||||
}
|
||||
loader, err := persona.NewLoader(personaPath)
|
||||
if err != nil {
|
||||
log.Printf("[iot-provider] 加载人格配置失败: %v", err)
|
||||
}
|
||||
@@ -190,7 +195,6 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
|
||||
Summary: "(未执行 IoT 操作)",
|
||||
}
|
||||
|
||||
// 提取用户消息
|
||||
userMessage := ""
|
||||
for i := len(subCtx) - 1; i >= 0; i-- {
|
||||
if subCtx[i].Role == model.RoleUser {
|
||||
@@ -207,98 +211,129 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 简单的关键词匹配来执行设备操作(不依赖 LLM 解析)
|
||||
// 这是作为降级方案,当 LLM 不可用时仍然可以处理基本 IoT 命令
|
||||
msgLower := strings.ToLower(userMessage)
|
||||
|
||||
// 尝试获取设备列表进行匹配
|
||||
devices := p.iotClient.GetDevicesForContext(ctx)
|
||||
log.Printf("[iot-provider] 📋 获取到 %d 个设备用于匹配", len(devices))
|
||||
|
||||
msgLower := strings.ToLower(userMessage)
|
||||
userName := extractUserName(subCtx)
|
||||
|
||||
// 收集所有匹配的设备-操作对,支持多设备命令
|
||||
type deviceAction struct {
|
||||
dev tools.IoTDevice
|
||||
operation string // "on" | "off" | "query"
|
||||
}
|
||||
var actions []deviceAction
|
||||
|
||||
for _, dev := range devices {
|
||||
devNameLower := strings.ToLower(dev.Name)
|
||||
|
||||
if !strings.Contains(msgLower, devNameLower) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 匹配到了设备名称
|
||||
if strings.Contains(msgLower, "打开") || strings.Contains(msgLower, "开") {
|
||||
if dev.Status != "on" && dev.Status != "open" {
|
||||
if dev.Type == "curtain" {
|
||||
// 窗帘使用 set 而非 toggle
|
||||
_ = p.iotClient.SetDeviceProperty(dev.ID, "status", "open")
|
||||
} else {
|
||||
_ = p.iotClient.ToggleDevice(dev.ID)
|
||||
// 判断此设备的操作:检查设备名附近的意图词
|
||||
devIdx := strings.Index(msgLower, devNameLower)
|
||||
contextStart := devIdx - 15
|
||||
if contextStart < 0 {
|
||||
contextStart = 0
|
||||
}
|
||||
contextEnd := devIdx + len(devNameLower) + 15
|
||||
if contextEnd > len(msgLower) {
|
||||
contextEnd = len(msgLower)
|
||||
}
|
||||
nearbyContext := msgLower[contextStart:contextEnd]
|
||||
|
||||
if strings.Contains(nearbyContext, "打开") || strings.Contains(nearbyContext, "开") {
|
||||
actions = append(actions, deviceAction{dev: dev, operation: "on"})
|
||||
} else if strings.Contains(nearbyContext, "关闭") || strings.Contains(nearbyContext, "关掉") || strings.Contains(nearbyContext, "关上") {
|
||||
actions = append(actions, deviceAction{dev: dev, operation: "off"})
|
||||
} else {
|
||||
actions = append(actions, deviceAction{dev: dev, operation: "query"})
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有匹配到具体设备,可能是查询所有设备状态
|
||||
if len(actions) == 0 {
|
||||
if strings.Contains(msgLower, "设备") && (strings.Contains(msgLower, "状态") || strings.Contains(msgLower, "怎么样") || strings.Contains(msgLower, "看看")) {
|
||||
if len(devices) > 0 {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("家里设备状态:\n")
|
||||
for _, d := range devices {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %s\n", d.Name, d.Status))
|
||||
}
|
||||
result.Summary = fmt.Sprintf("已帮%s打开%s♪", extractUserName(subCtx), dev.Name)
|
||||
result.Confidence = 0.9
|
||||
result.ToolCalls = []model.ToolCallRecord{{
|
||||
Name: "iot_control",
|
||||
Arguments: map[string]any{"device_id": dev.ID, "operation": "toggle"},
|
||||
Result: "success",
|
||||
}}
|
||||
log.Printf("[iot-subsession] 执行操作: 打开 %s (%s)", dev.Name, dev.ID)
|
||||
return result, nil
|
||||
} else {
|
||||
result.Summary = fmt.Sprintf("%s已经是打开状态啦~", dev.Name)
|
||||
result.Confidence = 0.9
|
||||
result.Summary = sb.String()
|
||||
result.Confidence = 0.7
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(msgLower, "关闭") || strings.Contains(msgLower, "关") {
|
||||
if dev.Status == "on" || dev.Status == "open" {
|
||||
if dev.Type == "curtain" {
|
||||
_ = p.iotClient.SetDeviceProperty(dev.ID, "status", "closed")
|
||||
} else {
|
||||
_ = p.iotClient.ToggleDevice(dev.ID)
|
||||
}
|
||||
result.Summary = fmt.Sprintf("已帮%s关闭%s~", extractUserName(subCtx), dev.Name)
|
||||
result.Confidence = 0.9
|
||||
result.ToolCalls = []model.ToolCallRecord{{
|
||||
Name: "iot_control",
|
||||
Arguments: map[string]any{"device_id": dev.ID, "operation": "toggle"},
|
||||
Result: "success",
|
||||
}}
|
||||
log.Printf("[iot-subsession] 执行操作: 关闭 %s (%s)", dev.Name, dev.ID)
|
||||
return result, nil
|
||||
} else {
|
||||
result.Summary = fmt.Sprintf("%s已经是关闭状态啦~", dev.Name)
|
||||
result.Confidence = 0.9
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 查询设备状态
|
||||
deviceStatus := fmt.Sprintf("%s当前状态: %s", dev.Name, dev.Status)
|
||||
if dev.Type == "light" && dev.Status == "on" {
|
||||
deviceStatus += fmt.Sprintf(" (亮度%d%%, 颜色%s)", dev.Brightness, dev.Color)
|
||||
} else if dev.Type == "ac" && dev.Status == "on" {
|
||||
deviceStatus += fmt.Sprintf(" (模式%s, 温度%.0f°C)", dev.Mode, dev.Temperature)
|
||||
}
|
||||
result.Summary = deviceStatus
|
||||
result.Confidence = 0.8
|
||||
log.Printf("[iot-provider] ❌ 未匹配到 IoT 操作: userMessage=%s", truncateStr(userMessage, 80))
|
||||
result.Summary = "(未匹配到 IoT 操作)"
|
||||
result.Confidence = 0.5
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 没有匹配到设备,可能只是查询所有设备状态
|
||||
if strings.Contains(msgLower, "设备") && (strings.Contains(msgLower, "状态") || strings.Contains(msgLower, "怎么样") || strings.Contains(msgLower, "看看")) {
|
||||
if len(devices) > 0 {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("家里设备状态:\n")
|
||||
for _, d := range devices {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %s\n", d.Name, d.Status))
|
||||
// 执行所有匹配到的操作
|
||||
var summaries []string
|
||||
var allToolCalls []model.ToolCallRecord
|
||||
executedCount := 0
|
||||
|
||||
for _, action := range actions {
|
||||
switch action.operation {
|
||||
case "on":
|
||||
if action.dev.Status != "on" && action.dev.Status != "open" {
|
||||
if action.dev.Type == "curtain" {
|
||||
_ = p.iotClient.SetDeviceProperty(action.dev.ID, "status", "open")
|
||||
} else {
|
||||
_ = p.iotClient.ToggleDevice(action.dev.ID)
|
||||
}
|
||||
summaries = append(summaries, fmt.Sprintf("已帮%s打开%s♪", userName, action.dev.Name))
|
||||
allToolCalls = append(allToolCalls, model.ToolCallRecord{
|
||||
Name: "iot_control",
|
||||
Arguments: map[string]any{"device_id": action.dev.ID, "operation": "toggle"},
|
||||
Result: "success",
|
||||
})
|
||||
log.Printf("[iot-subsession] 执行操作: 打开 %s (%s)", action.dev.Name, action.dev.ID)
|
||||
executedCount++
|
||||
} else {
|
||||
summaries = append(summaries, fmt.Sprintf("%s已经是打开状态啦~", action.dev.Name))
|
||||
}
|
||||
result.Summary = sb.String()
|
||||
result.Confidence = 0.7
|
||||
return result, nil
|
||||
case "off":
|
||||
if action.dev.Status == "on" || action.dev.Status == "open" {
|
||||
if action.dev.Type == "curtain" {
|
||||
_ = p.iotClient.SetDeviceProperty(action.dev.ID, "status", "closed")
|
||||
} else {
|
||||
_ = p.iotClient.ToggleDevice(action.dev.ID)
|
||||
}
|
||||
summaries = append(summaries, fmt.Sprintf("已帮%s关闭%s~", userName, action.dev.Name))
|
||||
allToolCalls = append(allToolCalls, model.ToolCallRecord{
|
||||
Name: "iot_control",
|
||||
Arguments: map[string]any{"device_id": action.dev.ID, "operation": "toggle"},
|
||||
Result: "success",
|
||||
})
|
||||
log.Printf("[iot-subsession] 执行操作: 关闭 %s (%s)", action.dev.Name, action.dev.ID)
|
||||
executedCount++
|
||||
} else {
|
||||
summaries = append(summaries, fmt.Sprintf("%s已经是关闭状态啦~", action.dev.Name))
|
||||
}
|
||||
case "query":
|
||||
deviceStatus := fmt.Sprintf("%s当前状态: %s", action.dev.Name, action.dev.Status)
|
||||
if action.dev.Type == "light" && action.dev.Status == "on" {
|
||||
deviceStatus += fmt.Sprintf(" (亮度%d%%, 颜色%s)", action.dev.Brightness, action.dev.Color)
|
||||
} else if action.dev.Type == "ac" && action.dev.Status == "on" {
|
||||
deviceStatus += fmt.Sprintf(" (模式%s, 温度%.0f°C)", action.dev.Mode, action.dev.Temperature)
|
||||
}
|
||||
summaries = append(summaries, deviceStatus)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[iot-provider] ❌ 未匹配到 IoT 操作: userMessage=%s", truncateStr(userMessage, 80))
|
||||
result.Summary = "(未匹配到 IoT 操作)"
|
||||
result.Confidence = 0.5
|
||||
result.Summary = strings.Join(summaries, "; ")
|
||||
result.Confidence = 0.9
|
||||
if len(allToolCalls) > 0 {
|
||||
result.ToolCalls = allToolCalls
|
||||
}
|
||||
if executedCount == 0 {
|
||||
result.Confidence = 0.8
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -336,5 +371,3 @@ func truncateStr(s string, maxLen int) string {
|
||||
return string(runes[:maxLen]) + "..."
|
||||
}
|
||||
|
||||
// Ensure json is used
|
||||
var _ = json.Marshal
|
||||
|
||||
Reference in New Issue
Block a user